Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 e7b92ae927 Add toScala conversion to CommandSelection
Adds a toScala() method to CommandSelection that converts proto-based
command selections to ScalaCommandSelection with proper Scala types.
This encapsulates the proto→Scala conversion logic that was previously
duplicated in each caller.

Updates the three action classes in library/ that use CommandSelection:
- EndHandleRiotsPhaseAction
- PerformVassalCommandsPhaseAction
- PerformVassalDefenseDecisionsAction

These now use cs.toScala(opac.commands) instead of manually converting
the selected command and finding the matching available command.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:35:31 -08:00
23ab715ffc Add GitHub mirror for busybox binary to fix CI download failures (#5210)
The busybox.net server is frequently unavailable or slow, causing CI
builds to fail with download timeouts. This adds a GitHub release
mirror as the primary download source with busybox.net as fallback.

The binary is hosted at:
https://github.com/nolen777/eagle0/releases/tag/busybox-1.35.0

SHA256 verified: 6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:17:08 -08:00
73f42d5b51 Add Mac download support to invitation landing page (#5207)
* Allow Mac signing/notarization/deploy on manual workflow triggers

The signing, notarization, and deploy steps were only running on
push events. Now they also run on workflow_dispatch (manual triggers)
when targeting main branch.

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

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

* Add Mac download support to invitation landing page

- Detect Mac vs Windows from User-Agent header
- Add /invite/{code}/install.sh endpoint for Mac shell installer
- Shell script creates invitation.json, downloads app ZIP, extracts to /Applications
- Update HTML template with platform-specific instructions
- Add MAC_INSTALLER_URL environment variable (default: assets.eagle0.net/mac/builds/eagle0-latest.zip)
- Show link to other platform at bottom of page

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:03:28 -08:00
3cb61f69f4 Make CommandFactory use Scala types instead of proto types (#5205)
Update TCommandFactory and CommandFactory to accept Scala AvailableCommand
and SelectedCommand types directly, eliminating proto dependencies from
the command execution path.

Key changes:
- TCommandFactory.makeTCommand now takes Scala command types
- CommandFactory pattern matching updated for all ~40 command types
- Helper methods updated (attackDecision, improvementTypeMap, etc.)
- Removed proto converter imports from CommandFactory
- Updated vassal/riot phase actions to convert proto→Scala at call sites
- Added exports for Scala command types from t_command_factory

All 175 library tests pass.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:58:05 -08:00
62fff3c0ea Fix warmup authentication for Docker bridge network connections (#5206)
The warmup tool sends X-Warmup-User header for authentication during
blue/green deployments. This only worked when connecting from true
localhost (127.0.0.1), but when running warmup from the host machine
to a Docker container, the connection appears to come from the Docker
bridge network (172.17.x.x), causing the warmup header to be ignored.

The CreateGameRequest then fails because the user is unauthenticated
(null username), causing the warmup to timeout.

Fix: Expand the localhost check to also accept Docker bridge network
addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x) using Java's
isSiteLocalAddress(). These are all private network addresses that
can only come from the same machine or local network.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:47:23 -08:00
ca86926a25 Fix AI to chase scattering defenders instead of holding empty castles (#5203)
When defenders scatter (flee from castles), the attacker AI was still
using HOLD_CASTLES strategy. This caused the AI to park units on
empty castles instead of chasing fleeing defenders, even though
eliminating all defenders wins via LAST_PLAYER_STANDING.

The fix adds a new condition to the strategy selector: if defenders
exist but none are on castles, use ATTACK_UNITS strategy to chase
them down.

New strategy selection flow:
1. Consider fleeing (if combat odds are bad)
2. Consider crossing rivers (if needed)
3. If attacker can't hold all castles → ATTACK_UNITS
4. If any defender is on a castle → ATTACK_CASTLES
5. NEW: If defenders exist but none on castles → ATTACK_UNITS
6. If no defenders remain → HOLD_CASTLES

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:34:47 -08:00
1d334c91ae Allow Mac signing/notarization/deploy on manual workflow triggers (#5204)
The signing, notarization, and deploy steps were only running on
push events. Now they also run on workflow_dispatch (manual triggers)
when targeting main branch.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:32:24 -08:00
64e1e46e0a Fix crane installation and use crane in blue-green deploys (#5202)
1. Fix crane installation - don't try to mv crane to itself
   (tar extracts to cwd which is already /opt/eagle0)
2. Keep crane binary after deploy for blue-green script to use
3. Update blue-green deploy to use crane instead of docker pull
   (fixes OCI/Docker digest mismatch issue)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:09:04 -08:00
4722a40384 Fix crane binary disappearing during Docker deploy (#5201)
Use absolute path for crane binary and add verification that it
was installed correctly. Also add ls -la output for debugging.

The crane binary was mysteriously disappearing between the Eagle
and Shardok image pulls.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:02:20 -08:00
6653a14660 Use Scala types for command matching in EngineImpl (#5200)
Refactors postCommand to:
- Get Scala commands directly from AvailableCommandsFactory
- Convert proto SelectedCommand to Scala for matching
- Use CommandType-based matching (no more proto dependency in AvailableCommandTypeMap)
- Convert back to proto for CommandFactory (temporary until full migration)

AvailableCommandTypeMap now uses only Scala types - matching is simply:
  availableCommands.find(_.commandType == selectedCommand.commandType)

Also adds:
- ScalaCommandSelection case class for future migration of command selectors
- Improved visibility for command converter targets

This is an incremental step toward eliminating proto commands from library/.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:59:01 -08:00
056571651e Upgrade rules_apple to 4.3.3 and rules_swift to 2.4.0 (#5198)
* Upgrade rules_apple to 4.3.3 and rules_swift to 2.4.0

These are the latest compatible versions (rules_apple 4.3.3 depends
on rules_swift 2.4.0 with compatibility level 2).

Note: rules_swift 3.x uses compatibility level 3 and is not
compatible with current rules_apple versions.

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

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

* Fix zipApp to handle symlinks in Sparkle.framework

Sparkle.framework contains symlinks like Headers -> Versions/Current/Headers.
The previous code used filepath.Walk which follows symlinks, causing it to
try to read a directory as a file.

Now uses filepath.WalkDir with os.Lstat to detect symlinks and store them
properly in the zip archive.

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

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

* Revert "Fix zipApp to handle symlinks in Sparkle.framework"

This reverts commit ad2f2e40d4562ced1b4001e13abc95d8901c8f0e.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:48:57 -08:00
d8224e7886 Fix zipApp to handle symlinks in Sparkle.framework (#5199)
Sparkle.framework contains symlinks like Headers -> Versions/Current/Headers.
The previous code used filepath.Walk which follows symlinks, causing it to
try to read a directory as a file.

Now uses filepath.WalkDir with os.Lstat to detect symlinks and store them
properly in the zip archive.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:48:29 -08:00
9cc9a65e4a Fix codesign script to sign all Sparkle framework components (#5195)
* Fix codesign script to sign all Sparkle framework components

Sign XPC services, nested apps (Updater.app), and standalone
executables (Autoupdate) before signing the framework itself.
Apple notarization requires all nested binaries to be signed
with Developer ID certificate and secure timestamp.

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

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

* Add missing path triggers for pull_request in Mac build workflow

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

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

* Re-add rules_swift for Mac GoDice plugin build

The DarwinGodiceBundle requires rules_swift to build.
This was inadvertently removed in #5194.

🤖 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-11 22:24:55 -08:00
025563b607 Add retry logic to docker pull in blue-green deploy (#5197)
Handles intermittent Docker registry digest mismatch errors like:
"failed commit on ref: unexpected commit digest"

This is a known Docker/containerd issue that can occur due to:
- Registry caching
- Network/proxy issues
- Race conditions during push

Now retries up to 3 times with 5 second delays.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:23:18 -08:00
030f6b2c2e Fix warmup tool and PostCommandResponse status (#5196)
Warmup tool fixes:
- Process GameUpdates while waiting for PostCommandResponse (action
  results arrive BEFORE PostCommandResponse, not after)
- Don't wait for SubscriptionAck before ActionResultResponse (they
  arrive in reverse order)
- Use recommended_hero_id from ImproveAvailableCommand instead of
  hardcoding 0 (which doesn't exist)
- Verify we receive new AvailableCommands after posting command
- Add detailed logging for debugging

Server fix:
- Return PostCommandResponse.Status.SUCCESS instead of default UNKNOWN

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:54:41 -08:00
1d5dd7b971 Remove swift_proto_library targets and rules_swift dependency (#5194)
The Mac history editor that used these targets was previously removed.
Cleaning up the unused Swift proto infrastructure.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:24:54 -08:00
649e80c4ac Add Mac build pipeline with Sparkle auto-updates (#5193)
- Unity Mac build scripts (build_mac.sh, build_unity_mac.sh)
- Code signing with Developer ID certificate
- Apple notarization for Gatekeeper compliance
- Sparkle framework injection for delta auto-updates
- Go build handler for S3 upload and appcast.xml generation
- Update InvitationCodeManager.cs for Mac platform paths
- GitHub Actions workflow triggered on main branch pushes

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:22:52 -08:00
a1b4e41553 Add CommandType enum to replace SelectedCommand in ActionResult and Province (#5191)
* Add CommandType enum to replace SelectedCommand in ActionResult and Province

Create a compile-time safe CommandType enum that provides exhaustive matching
when adding new command types. This replaces SelectedCommand in:
- ActionResult.lastCommandTypeForActingProvince
- Province.lastCommand

Key changes:
- New CommandType.scala enum with exhaustive converters from SelectedCommand
  and AvailableCommand
- New command_type.proto with 40 command types + UNKNOWN
- CommandTypeConverter for proto<->Scala conversion
- Simplified shouldFollow in AvailableCommandsFactory to compare CommandType
  values directly
- Updated RandomStateSequencer.withTCommandAndLastCommand to use CommandType

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

* Add backwards compatibility for CommandType in saved games

Preserve the deprecated SelectedCommand field (field 28) alongside the
new CommandType field (field 43) in action_result.proto. When loading
saved games, first check the new CommandType field; if not set, fall
back to the deprecated SelectedCommand and convert it.

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

* Use parameterized enums for SelectedCommand/AvailableCommand to CommandType mapping

Refactors the dependency direction: instead of CommandType.from(SelectedCommand),
each enum case now has a built-in `val commandType: CommandType` parameter.

Benefits:
- Compile-time safety: can't add a new command without specifying its CommandType
- Simpler access: just call .commandType instead of a converter method
- Better dependency direction: richer types depend on simpler types, not vice versa

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:58:34 -08:00
1c24474a0b Add critical git rules to top of CLAUDE.md (#5192)
Prevent future accidental pushes directly to main by putting
explicit rules at the very top of the file.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:31:36 -08:00
adminandClaude Opus 4.5 26eec6cabc Add tutorial system plan document
Documents current implementation status, architecture, and remaining work.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:15:27 -08:00
d134f40438 Add tutorial test setup with IMGUI fallback UI (#5173)
* Add tutorial test setup with IMGUI fallback UI

Create TutorialTestSetup component that:
- Registers test tutorials programmatically on startup
- Triggers on first province selection, battle entry, and command issued
- Allows testing without Unity Editor asset creation

Add fallback IMGUI modal in TutorialUIManager:
- Renders when no ModalPanel prefab is assigned
- Shows title, description, progress, and action buttons
- Enables end-to-end testing without UI prefab setup

To test:
1. Add TutorialTestSetup component to TutorialManager GameObject
2. Ensure TutorialManager has UIManager reference
3. Play game and select a province to see test tutorial

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

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

* Silence missing onboarding sequence warning

Change LogWarning to debug log when no onboarding sequence is assigned.
This is a valid configuration state, not an error.

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

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

* Improve IMGUI fallback modal styling

- Add FallbackFont field (assign Stoke-Regular.ttf in Unity)
- Increase font sizes: title 24, description 20, buttons 18
- Make modal window larger (600x300)
- Make buttons taller (40px height)

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

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

* Double IMGUI modal size and font sizes

- Window: 1200x600 (was 600x300)
- Title: 48pt, Description: 40pt, Progress: 32pt, Buttons: 36pt
- Buttons: 200-240x80 (was 100-120x40)
- Add TutorialManager GameObject to Gameplay scene

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

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

* Add Reset Tutorials button to Settings panel

- Add resetTutorialsButton field to SettingsPanelController
- Add OnResetTutorialsClick() handler that calls TutorialManager.ResetAllProgress()

To wire up in Unity:
1. Add a Button to the Settings panel
2. Assign it to resetTutorialsButton field
3. Set OnClick to SettingsPanelController.OnResetTutorialsClick

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

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

* Fix ambiguous Debug reference

Use UnityEngine.Debug.Log to resolve conflict with System.Diagnostics.Debug

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

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

* Apply window style to IMGUI modal title

Pass windowStyle to GUI.Window so title uses 48pt font

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

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

* Add more spacing between title and description

Increase top spacing from 30 to 60 pixels

🤖 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-11 11:38:21 -08:00
a638dd26ca Fix deploy: remove warmup before scp, safer container cleanup (#5190)
Three fixes:
1. Remove existing warmup binary before scp - Bazel outputs it with
   read-only permissions (r-xr-xr-x), causing scp to fail on overwrite.

2. Remove the `docker compose up -d --remove-orphans` step which was
   causing "container name already in use by service {}" errors due to
   Docker Compose state conflicts.

3. Add `docker container prune -f` as a safer alternative - removes
   stopped containers without trying to reconcile compose state.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:46:29 -08:00
5e93b0eaa0 Add null safety to RunningGamePlayerInfo in admin console (#5189)
Prevent NPE when listing games with corrupt faction/leader data.
This defensive fix handles three cases:
- Hero might not exist for the faction head ID
- Faction name might be null
- Leader nameTextId might be null

The warmup tool created games with unexpected null values, causing
the admin console to crash when listing running games.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:22:12 -08:00
6940312d3f Add /invite/ route to nginx config (#5188)
The invitation landing page is served by the auth service on port 8080,
but nginx wasn't configured to proxy requests to it, resulting in 404.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:18:26 -08:00
243f0d43e7 Fix scp to preserve directory structure for deploy scripts (#5187)
scp flattens paths - it was copying scripts/bin/warmup to /opt/eagle0/warmup
instead of /opt/eagle0/scripts/bin/warmup. Fixed by:
1. Creating directory structure on remote first
2. Copying files to their correct locations

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:44:25 -08:00
1ddd015449 Unify .env management across workflows with shared update script (#5186)
Both docker_build.yml and auth_build.yml now use a shared update-env.sh
script that updates only the variables each workflow is responsible for,
without overwriting values set by other workflows.

Changes:
- Add deploy/env.template with all environment variables
- Add deploy/update-env.sh to safely update individual env vars
- Update docker_build.yml to use update-env.sh instead of rm/recreate
- Update auth_build.yml to use update-env.sh instead of grep/sed chain
- Add FASTMAIL_* vars to docker_build.yml deploy job

This fixes the bug where docker_build.yml was overwriting FASTMAIL env
vars set by auth_build.yml because it recreated .env from scratch.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:42:08 -08:00
da85d0983a Fix deploy order: start jfr-sidecar after eagle-blue (#5185)
jfr-sidecar has `pid: "service:eagle-blue"` to share PID namespace for
JFR profiling. It must be started after eagle-blue exists, not before.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:31:12 -08:00
ae574e6ff5 Add delete user and delete invitation functionality to admin panel (#5181)
- Add DeleteUser and DeleteInvitation RPCs to admin.proto
- Add Delete methods to UserService and InvitationService
- Add delete handlers in admin_handlers.go and admin_server.go
- Add delete buttons to users and invitations admin UI templates
- Users can be deleted permanently (with self-deletion prevention)
- Only non-pending invitations (revoked, expired, redeemed) can be deleted

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:23:03 -08:00
c1ac57b929 Build warmup tool in deploy job before scp (#5183)
The warmup binary was being built in build-eagle but each job has its
own checkout, so the binary wasn't available in the deploy job when
we tried to scp it to the droplet.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:22:21 -08:00
be51daa148 Remove EagleGameHistoryViewer Mac app (#5182)
This Swift/SwiftUI app for viewing game history is no longer needed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:21:25 -08:00
97605d0a63 Remove mac history editor build workflow (#5180)
The mac history editor is no longer needed now that we have the Admin
console for game management.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:17:48 -08:00
9cca922b70 Add blue-green deployment infrastructure for Eagle server (#5162)
* Add blue-green deployment infrastructure for Eagle server

- Add ReloadGames RPC to eagle.proto for reloading game state from disk
- Implement reloadAllGames() and flushToDisk() in GamesManager.scala
- Add reloadGames() handler to EagleServiceImpl.scala
- Update docker-compose.prod.yml with eagle-blue/green services
- Update nginx.conf for switchable upstream
- Create scripts/deploy-blue-green.sh for zero-downtime deployment
- Create Go warmup tool (src/main/go/net/eagle0/warmup) that:
  - Uses bidirectional streaming to create test games
  - Posts Improve command and verifies ActionResults
  - Cleans up test game after warmup
- Create scripts/warmup-eagle.sh wrapper that uses Go tool or falls back to grpcurl
- Update docker_build.yml to build and deploy warmup binary

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

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

* Add X-Warmup-User header support with localhost restriction

- AuthorizationInterceptor: Accept X-Warmup-User header for warmup authentication
- Only allow X-Warmup-User from localhost connections (security)
- Go warmup tool: Send x-warmup-user metadata header
- Add grpc/metadata dependency to warmup BUILD

🤖 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-10 22:13:15 -08:00
6eab9ffc3c Add invitation landing page with one-click installer (#5165)
Instead of asking users to copy/paste a PowerShell command, emails now
link to a landing page at /invite/{code} that:
- Shows a branded "Accept Invitation" page
- Offers a "Download & Install" button that downloads a .bat file
- The .bat file downloads the installer and runs it with --code=XXX
- Shows clear instructions for running the .bat file
- Displays error messages for invalid/expired/redeemed codes
- Falls back to showing the invitation code for manual entry

Changes:
- Add invitation_handlers.go with landing page and .bat download routes
- Update main.go to register the new HTTP routes
- Simplify email template to just link to the landing page

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:10:09 -08:00
08eaffb927 Remove debug logging from combat animators (#5172)
Remove Debug.Log calls from ArrowVolleyAnimator and MeleeAnimator.
Keep Debug.LogWarning calls that indicate configuration issues.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:51:28 -08:00
a2bc55e0f5 Remove debug logging from MeleeAnimator (#5179)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:51:05 -08:00
19861377a6 Use hex center for animation positions (#5178)
Changed all animators to use GetCellCenterPosition instead of
GetCellLocalPosition so animations start and end at the actual
hex center rather than offset towards the top.

- MoveAnimator: footprints centered
- MeleeAnimator: weapon animations centered
- CatapultAnimator: projectile source now also centered (target already was)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:21:14 -08:00
cc70087efc Fix captured hero visibility in AvailableCommandConverter (#5177)
The converter was using factionId = Some(viewingFactionId) which
applied visibility restrictions to captured heroes. The old proto-based
factory used factionId = None (with comment "can see unaffiliated
heroes") which always showed full hero info.

When handling captured heroes, the player needs to see all hero stats
to make informed decisions about recruiting, imprisoning, etc.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:14:33 -08:00
b2383c8384 Fix Move animation direction for observed battles (#5176)
When observing battles, Move animations were playing backwards because
the model is fully updated before animations play. The animation code
was using the unit's current location (post-move) as the source, but
for multi-step moves this caused animations to go from the final
position back to intermediate positions.

Fix: Track source coordinates in ShardokGameModel.MoveSourceCoords
before applying each diff, then use these stored coordinates for
animation source positions.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:33:58 -08:00
03958b6914 Fix ArmyStats missing hostility and originProvinceId fields (#5175)
Add hostility and originProvinceId fields to Scala ArmyStats that were
present in the proto definition but missing from the Scala model. This
fixes the AttackDecisionCommandChooser which uses hostility to determine
friendly vs enemy armies.

Unlike #5174, this uses required fields instead of defaults, ensuring
all call sites explicitly provide the values rather than relying on
potentially incorrect defaults.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:03:49 -08:00
4cbf3ea39f Integrate tutorial system hooks into game controllers (#5171)
Add tutorial trigger hooks to both strategic (EagleGameController) and
tactical (ShardokGameController) layers to enable the tutorial system
to respond to game events.

Strategic layer hooks:
- TutorialManager initialization with auto-start onboarding
- OnModelUpdated for game state changes
- OnProvinceSelected for province selection
- OnCommandIssued for command submission

Tactical layer hooks:
- TutorialManager initialization on battle entry
- OnBattleEntered when entering combat
- OnBattleAction for each action result
- OnUnitSelected for tile selection
- OnTurnEnded for turn completion

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:16:59 -08:00
3dbbe74830 Use self-hosted runner for deploy job (#5170)
Replace Docker-based appleboy/scp-action and appleboy/ssh-action with
native scp and ssh commands. This eliminates the Docker container build
overhead that was causing ~3 minute delays during each deploy.

Changes:
- deploy job now runs on self-hosted runner
- Use native scp to copy files to droplet
- Use native ssh with heredoc for deployment script
- Add DO_DROPLET_IP and DO_REGISTRY_TOKEN to env block

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:14:15 -08:00
dab4365f16 Fix race condition in ShardokGameController setup (#5169)
The UpdateAction callback was being set before hexGrid.SetUp() was called.
Since SetUp() is queued via MainQueue, game updates arriving in between
would call ModelUpdated() before cells were initialized.

Fixed by:
1. Moving Model.UpdateAction assignment inside the queued block, after SetUp()
2. Added defensive null checks in HexGrid.SetProfessionImage/SetUnitTypeImage

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:57:21 -08:00
0b3cd4f1c6 Fix melee animation weapons persisting after animation ends (#5167)
When a melee animation was cancelled (either by starting a new animation
or calling CancelAnimation), the weapon GameObjects were not destroyed
because they were local variables in the coroutine.

Fixed by storing weapons in class-level fields and adding a CleanupWeapons()
method that is called when animations are cancelled or complete normally.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:52:27 -08:00
49d42a599a Fix missing eligibleStatuses in diplomacy resolve commands (#5168)
The Scala DiplomacyOfferInfo was missing the eligibleStatuses field,
causing the client to not know what actions are available when
resolving diplomacy offers (alliance, truce, ransom, invitation,
break alliance).

Changes:
- Add eligibleStatuses: Vector[Status] to DiplomacyOfferInfo
- Update all resolve command factories to populate eligibleStatuses
- Update AvailableCommandConverter to apply eligibleStatuses to proto

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:44:18 -08:00
5ae7d8a9cc Fix NullReferenceException in StopAll when ModelUpdater is null (#5166)
StopAll() could be called before a game was set up (ModelUpdater not
initialized) or called multiple times (second call after ModelUpdater
was set to null). Added null check to prevent the crash.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:29:46 -08:00
ee47f7d6bc Switch from SMTP to Fastmail JMAP API for invitation emails (#5164)
DigitalOcean blocks SMTP ports (587, 465) by default. Switch to
Fastmail's JMAP API which uses HTTPS and is not blocked.

Changes:
- Rewrite sendgrid.go to use JMAP API (Email/set + EmailSubmission/set)
- Update docker-compose.prod.yml with FASTMAIL_* env vars
- Update auth_build.yml workflow with new secrets

Required GitHub secrets:
- FASTMAIL_API_TOKEN: API token with email submission scope
- FASTMAIL_FROM_EMAIL: Sender email (optional, uses identity default)
- FASTMAIL_FROM_NAME: Sender name (optional, defaults to "Eagle0 Game")

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:54:10 -08:00
fe3c2d3e79 Add SMTP env vars to auth service in docker-compose (#5163)
The workflow writes SMTP credentials to .env, but docker-compose only
passes explicitly listed environment variables to containers.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:38:49 -08:00
de21646a37 Pass SMTP credentials to auth service container (#5161)
Add SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_EMAIL, and SMTP_FROM_NAME
to the deploy job so the auth service can send invitation emails.

The credentials are now:
1. Mapped from GitHub secrets in the env section
2. Passed via SSH in the envs parameter
3. Written to .env file on the production server

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:34:08 -08:00
040f2d55b9 Replace SendGrid with standard SMTP for email sending (#5159)
Use Go's net/smtp package with STARTTLS instead of SendGrid API.
This allows using Fastmail (or any SMTP provider) without DNS changes.

Environment variables:
- SMTP_HOST (default: smtp.fastmail.com)
- SMTP_PORT (default: 587)
- SMTP_USERNAME
- SMTP_PASSWORD (app password)
- SMTP_FROM_EMAIL
- SMTP_FROM_NAME

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:40:00 -08:00
5b32712b0f Add invitation code support to installer and Unity client (#5158)
* Add invitation code capture to Windows installer

Support --code=XXXX command line argument to pass invitation codes.
The code is saved to invitation.json in the install directory for
the Unity client to read during OAuth.

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

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

* Add invitation code support to Unity client OAuth flow

- Add InvitationCodeManager to read codes from installer file or PlayerPrefs
- Pass invitation code in GetOAuthUrlRequest
- Handle OAUTH_STATUS_INVITATION_REQUIRED response
- Clear invitation code after successful new account creation
- Add OnInvitationRequired event for UI handling

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

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

* Add PowerShell install option and manual code entry UI

Email template:
- Add Option 1: PowerShell one-liner to download and run with code
- Add Option 2: Manual download with code entry instructions

Unity client:
- Add invitation code entry panel UI references
- Handle OnInvitationRequired event to show code entry
- OnSubmitInvitationCodeClicked saves code and returns to login

🤖 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-10 15:54:11 -08:00
bdec4a0015 Tutorial system foundation (#5155)
* Add tutorial system foundation

Create the core architecture for a comprehensive tutorial system:

- TutorialState: PlayerPrefs-based persistence for tutorial progress
- TutorialStep/TutorialSequence: Data structures for tutorial content
- TutorialManager: Singleton coordinating state, triggers, and UI
- TutorialTriggerRegistry: Event-based trigger system for contextual tutorials
- TutorialUIManager: Coordinates modal, overlay, and hint UI components
- TutorialModalPanel: Full-screen modal dialogs for important tutorials
- TutorialOverlayController: Highlighting UI elements with tooltips
- TutorialHintIndicator: Subtle pulsing hints on UI elements

Supports:
- Guided onboarding sequences for new players
- Contextual tutorials triggered on first encounter/attempt
- Mixed UI: modals, overlays, and hint indicators
- Skip/dismiss functionality
- Progress persistence via PlayerPrefs

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

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

* Fix missing namespace imports in tutorial system

Add using statements for eagle and Shardok namespaces to resolve
compiler errors referencing EagleGameController, ShardokGameController,
and IGameModel.

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

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

* Add Unity meta files for tutorial system

Unity requires .meta files for all assets including scripts and
directories. These are needed for the Unity build to succeed.

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

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

* Rename namespace to Eagle0.Tutorial to avoid conflict

The Eagle namespace is used by generated protobuf code (Eagle.EagleClient).
Using Eagle.Tutorial was shadowing this, causing compilation errors.
Renamed to Eagle0.Tutorial to avoid the conflict.

🤖 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-10 15:52:58 -08:00
c2c7b85da5 Make AvailableCommandsFactory return Scala types instead of proto (#5157)
* Make AvailableCommandsFactory return Scala types instead of proto

Convert AvailableCommandsFactory to work entirely with Scala types internally
and return Scala OneProvinceAvailableCommands. Proto conversion now happens at
API boundaries (EngineImpl) rather than inside the factory. This continues the
protoless migration by pushing proto dependencies to the edges of the system.

- Add Scala OneProvinceAvailableCommands case class
- Add OneProvinceAvailableCommandsConverter for proto conversion
- Update AvailableCommandsFactory to return Scala types
- Update callers (EngineImpl, RoundPhaseAdvancer, action classes)
- Remove proto overloads from diplomacy resolution factories
- Update tests to work with new Scala types

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

* Remove dead proto code from AvailablePleaseRecruitMeCommandFactory

Delete unused proto overload and ExpandedUnaffiliatedHeroUtils which
was only used by the proto code path.

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

* Fix missing DEVASTATION case in SelectedCommandConverter

Add missing case for ImprovementTypeProto.DEVASTATION in the
improvementTypeFromProto match expression.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:34:50 -08:00
9e287ba3bb Add invitation-based account creation system (#5156)
* Add invitation-based account creation system (Phase 1-2)

Implement invitation system to restrict new account creation to
invited users only. Existing users are grandfathered.

Proto definitions:
- Add Invitation, InvitationStatus, InvitationDatabase to user.proto
- Add invitation management RPCs to admin.proto (CreateInvitation,
  ListInvitations, RevokeInvitation, ResendInvitation)
- Add invitation_code field to GetOAuthUrlRequest
- Add OAUTH_STATUS_INVITATION_REQUIRED status

Go auth service:
- Add InvitationService for managing invitations with persistence
- Add EmailService for SendGrid integration (disabled if API key not set)
- Update OAuth flow to pass invitation code through state
- Validate invitation code for new users in CheckOAuthStatus
- Add admin handlers for invitation management

Environment variables:
- SENDGRID_API_KEY: Required for email sending
- SENDGRID_FROM_EMAIL: Sender email (default: noreply@eagle0.net)
- SENDGRID_FROM_NAME: Sender name (default: Eagle0 Game)
- INSTALLER_DOWNLOAD_URL: URL for installer download link

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

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

* Add invitation management UI to admin panel (Phase 3)

- Add "Invitations" link to navigation
- Create invitations.html and invitations_rows.html templates
- Add invitation management handlers:
  - handleInvitationsPage: List all invitations with filtering
  - handleInvitationsSearch: Search/filter invitations (htmx)
  - handleCreateInvitation: Create and send invitation
  - handleResendInvitation: Resend invitation email
  - handleRevokeInvitation: Revoke a pending invitation

Features:
- Status filtering (All/Pending/Redeemed/Expired/Revoked)
- Email search
- Create invitation modal with expiration days
- Resend and Revoke actions for pending invitations
- Copy invitation code to clipboard

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

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

* Add feature flag for invitation code requirement

Add REQUIRE_INVITATION_CODE environment variable to control whether
new users must provide invitation codes. Defaults to false, allowing
the invitation system to be deployed without immediately blocking
new signups.

- Add isInvitationRequired() function that checks env var
- Only validate invitation codes when feature flag is enabled
- Log feature flag status at startup

🤖 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-10 15:19:19 -08:00
1a0cfe8578 Remove assets/ prefix from installer paths (#5154)
The old server had an assets/ prefix in its routing, but DO Spaces
CDN serves files directly from the bucket root.

- Old: https://eagle0.net/assets/installer/...
- New: https://assets.eagle0.net/installer/...

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:10:01 -08:00
a841720c75 Remove factory adapter infrastructure (#5152)
Now that all Available*CommandFactory classes use ScalaAvailableCommandsFactory,
remove the adapter infrastructure:
- Delete UnifiedCommandFactory trait
- Delete LegacyFactoryAdapter and ScalaFactoryAdapter
- Delete AvailableCommandsFactoryForType trait
- Update AvailableCommandsFactory to use ScalaAvailableCommandsFactory directly
- Update tests to mock ScalaAvailableCommandsFactory

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:02:07 -08:00
064a606d13 Hardcode CDN URL to fix auto-update from old installers (#5153)
Old installers saved eagle0.net to the registry. The new installer
(without auth) was reading that saved URL and failing with 401.

Now the URL is hardcoded to assets.eagle0.net with no registry storage.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 10:58:45 -08:00
299 changed files with 9949 additions and 53513 deletions
+26 -24
View File
@@ -105,10 +105,23 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -116,34 +129,23 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Update AUTH_IMAGE in .env file (preserve other vars)
if [ -f .env ]; then
# Remove old AUTH_IMAGE line and add new one
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
# Also update OAuth credentials
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
# Update JWT private key (JWK format for auth service bootstrap)
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
chmod 600 .env
else
echo "ERROR: .env file not found. Run main deploy first."
exit 1
fi
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
+193 -118
View File
@@ -42,6 +42,13 @@ jobs:
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Also build the warmup tool for Linux
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Copy warmup binary to scripts/ so it gets deployed with other scripts
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
@@ -421,7 +428,7 @@ jobs:
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
deploy:
runs-on: ubuntu-latest
runs-on: self-hosted
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
@@ -443,135 +450,203 @@ jobs:
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build warmup tool
run: |
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
- name: Copy config files to droplet
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
run: |
# Create directory structure on remote
# Use -p to create parents, and test write access before copying
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
set -e
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
# Remove existing warmup binary (it may have read-only permissions from bazel)
rm -f /opt/eagle0/scripts/bin/warmup
SETUP_DIRS
# Copy files preserving structure
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
- name: Deploy to production droplet
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,SHARDOK_ADDRESS,SHARDOK_AUTH_TOKEN,SENTRY_DSN
script: |
set -x
cd /opt/eagle0
run: |
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
set -ex
cd /opt/eagle0
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
fi
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
rm -f crane
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
if [ ! -x crane ]; then
echo "ERROR: Failed to install crane"
ls -la crane || true
exit 1
fi
echo "Crane installed"
ls -la crane
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "\${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
# Keep crane for blue-green deploys (don't delete it)
echo "Crane kept at ./crane for future blue-green deploys"
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
# Note: jfr-sidecar must start after eagle-blue due to PID namespace sharing
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin
# Deploy Eagle with blue-green (zero-downtime) if scripts are available
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
echo "Using blue-green deployment for Eagle..."
chmod +x /opt/eagle0/scripts/*.sh
# Make warmup binary executable if present
if [ -f "/opt/eagle0/scripts/bin/warmup" ]; then
chmod +x /opt/eagle0/scripts/bin/warmup
fi
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}" || {
echo "Blue-green deployment failed, falling back to direct restart"
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
}
else
echo "Blue-green scripts not found, using direct restart..."
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
fi
# Write env vars to .env file for docker-compose
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
EXISTING_AUTH_IMAGE=""
if [ -f .env ]; then
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
fi
# Start jfr-sidecar after eagle-blue (shares PID namespace with eagle-blue)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate jfr-sidecar
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=${EAGLE_IMAGE}
SHARDOK_IMAGE=${SHARDOK_IMAGE}
ADMIN_IMAGE=${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
SHARDOK_ADDRESS=${SHARDOK_ADDRESS:-shardok:40042}
SHARDOK_AUTH_TOKEN=${SHARDOK_AUTH_TOKEN:-}
SENTRY_DSN=${SENTRY_DSN:-}
EOF
chmod 600 .env
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
# This prevents restarting auth service during every Eagle deploy
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Cleanup orphaned containers
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
# Cleanup stopped containers and old images
docker container prune -f
docker image prune -f
DEPLOY_SCRIPT
+149
View File
@@ -0,0 +1,149 @@
name: Mac Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
pull_request:
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
workflow_dispatch:
inputs:
skip_notarization:
description: 'Skip notarization (for testing)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
jobs:
mac-unity:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: false
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- name: Restore Library/
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/
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: Import Code Signing Certificate
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password (only used within this workflow run)
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
# Allow codesign to access keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up
rm certificate.p12
- name: Code Sign App
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Notarize App
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event.inputs.skip_notarization != 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_mac_app.sh
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Deploy Mac Build
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
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" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
-29
View File
@@ -1,29 +0,0 @@
name: Mac History Editor Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
pull_request:
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
permissions:
contents: read
jobs:
mac-history-build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build the mac history
run: ./ci/github_actions/build_mac_history.sh
+14
View File
@@ -1,5 +1,19 @@
# CLAUDE.md
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
---
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
+12 -5
View File
@@ -127,8 +127,8 @@ use_repo(
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
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")
#
# Protocol Buffers & RPC
@@ -294,11 +294,15 @@ http_archive(
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# https://busybox.net/downloads/binaries/
# Primary: GitHub release mirror (reliable)
# Fallback: busybox.net (can be unreliable/slow)
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
urls = [
"https://github.com/nolen777/eagle0/releases/download/busybox-1.35.0/busybox-1.35.0-x86_64-linux-musl",
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
@@ -306,7 +310,10 @@ http_file(
http_file(
name = "busybox_aarch64",
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
urls = [
# TODO: Upload aarch64 binary to GitHub release when needed
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euxo pipefail
. ./ci/unity_version.sh
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
BUILD_DIR=$1
LOG_PATH=$2
echo "Building Mac in $BUILD_DIR"
echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -euxo pipefail
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
git log -3
/bin/echo "build Mac"
LOG_PATH="/tmp/eagle0/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+18
View File
@@ -0,0 +1,18 @@
<?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>
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory (required for Unity) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Disable library validation (required for plugins) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow outgoing network connections -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+40
View File
@@ -0,0 +1,40 @@
# Environment template for production deployment
# This file defines all env vars used by docker-compose.prod.yml
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection
SHARDOK_ADDRESS=shardok:40042
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Update .env file without losing other variables
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
#
# This script:
# 1. Creates .env from template if it doesn't exist
# 2. Updates only the specified KEY=value pairs
# 3. Preserves all other existing values
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
# Create .env from template if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "Creating .env from template..."
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
else
echo "Creating empty .env..."
touch "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
fi
# Process each KEY=VALUE argument
for arg in "$@"; do
# Skip empty args
[ -z "$arg" ] && continue
# Parse KEY=VALUE
KEY="${arg%%=*}"
VALUE="${arg#*=}"
# Skip if no key
[ -z "$KEY" ] && continue
# Skip setting empty values (keeps existing value)
if [ -z "$VALUE" ]; then
echo "Skipping $KEY (empty value)"
continue
fi
# Remove existing line for this key and add new one
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
chmod 600 "$ENV_FILE"
echo "Done updating $ENV_FILE"
+68 -8
View File
@@ -8,9 +8,13 @@
# Run: docker compose -f docker-compose.prod.yml up -d
services:
eagle:
# Blue-green deployment: eagle-blue is the primary (production) instance
# eagle-green is the staging instance for zero-downtime deployments
# See scripts/deploy-blue-green.sh for deployment workflow
eagle-blue:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-server
container_name: eagle-blue
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
@@ -39,7 +43,7 @@ services:
volumes:
- ./saves:/app/saves # Game saves and user database
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
@@ -58,6 +62,58 @@ services:
retries: 3
start_period: 30s
eagle-green:
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
SENTRY_DSN: "${SENTRY_DSN:-}"
SENTRY_ENVIRONMENT: "production"
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
# Backward compatibility alias - for scripts that reference 'eagle' service
eagle:
extends:
service: eagle-blue
auth:
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
container_name: auth-server
@@ -80,6 +136,10 @@ services:
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
JWT_KEYS_PATH: "/etc/eagle0/keys"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
# Fastmail JMAP API for sending invitation emails
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
@@ -136,7 +196,7 @@ services:
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle
- eagle-blue
- admin
restart: unless-stopped
logging:
@@ -150,7 +210,7 @@ services:
container_name: admin-server
command:
- "--eagle-addr"
- "eagle:40032"
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
@@ -159,7 +219,7 @@ services:
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle
- eagle-blue
- auth
- jfr-sidecar
restart: unless-stopped
@@ -179,11 +239,11 @@ services:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
pid: "service:eagle"
pid: "service:eagle-blue"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle
- eagle-blue
restart: unless-stopped
logging:
driver: "json-file"
+10 -1
View File
@@ -25,8 +25,10 @@ http {
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
upstream eagle_grpc {
server eagle:40032;
server eagle-blue:40032;
keepalive 100;
}
@@ -106,6 +108,13 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Code sign a macOS .app bundle for distribution
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
fi
echo "=== Signing nested components first ==="
# Sign all dylibs
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
echo "Signing dylib: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all bundles (plugins)
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
echo "Signing bundle: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (inside Sparkle framework)
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
echo "Signing XPC service: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign nested apps (like Sparkle's Updater.app)
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
echo "Signing nested app: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign standalone executables inside frameworks (like Autoupdate)
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
echo "Signing executable: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all frameworks (after their contents are signed)
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
echo "=== Signing main app bundle ==="
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
echo "Using entitlements: $ENTITLEMENTS_PATH"
codesign --force --verify --verbose --timestamp --options runtime \
--entitlements "$ENTITLEMENTS_PATH" \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
else
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
fi
echo "=== Verifying signature ==="
codesign --verify --verbose=4 "$APP_PATH"
echo "=== Checking Gatekeeper assessment ==="
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
echo "Code signing complete: $APP_PATH"
+247
View File
@@ -0,0 +1,247 @@
#!/bin/bash
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to disk
# 5. Telling green to reload games from disk
# 6. Switching nginx to route traffic to green
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
# Example:
# ./deploy-blue-green.sh latest
# ./deploy-blue-green.sh sha-abc123
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
pull_with_retry() {
local image=$1
local max_attempts=${2:-3}
local attempt=1
# Use crane if available (handles OCI format correctly)
if [ -x "${APP_DIR}/crane" ]; then
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
rm -f /tmp/image.tar
log_info "Image pulled and loaded successfully"
return 0
fi
rm -f /tmp/image.tar
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
else
# Fallback to docker pull if crane not available
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
if docker pull "${image}"; then
log_info "Image pulled successfully"
return 0
fi
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
fi
log_error "Failed to pull image after ${max_attempts} attempts"
return 1
}
# Wait for a container to be healthy
wait_for_healthy() {
local container=$1
local max_attempts=${2:-60}
local attempt=1
log_info "Waiting for ${container} to become healthy..."
while [ $attempt -le $max_attempts ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
log_info "${container} is healthy"
return 0
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
echo ""
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
return 1
}
# Main deployment logic
main() {
local new_tag="${1:-latest}"
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
log_info "Starting blue-green deployment"
log_info "New image: ${new_image}"
cd "${APP_DIR}"
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ]; then
staging="green"
else
staging="blue"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
exit 1
fi
# Start staging instance with new image
log_info "Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
else
staging_port=40032
fi
log_info "Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
log_warn "JIT will be cold on first requests"
fi
# Stop the active instance (this flushes state to disk)
log_info "Stopping eagle-${active} (flushing state to disk)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Tell staging to reload games from disk
log_info "Telling eagle-${staging} to reload games from disk..."
if command -v grpcurl &> /dev/null; then
grpcurl -plaintext -d '{}' "localhost:${staging_port}" net.eagle0.eagle.api.Eagle/ReloadGames || true
else
log_warn "grpcurl not installed, skipping game reload"
log_warn "New instance will use games loaded at startup"
fi
# Switch nginx upstream
log_info "Switching nginx upstream to eagle-${staging}..."
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Clean up old instance
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
# Update the staging instance's restart policy and image var
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
if [ "$staging" = "blue" ]; then
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
# User should update their .env file
else
log_info "Green is now active. Consider switching to blue on next deployment."
fi
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
log_info ""
log_info "Note: Update your .env file with EAGLE_IMAGE=${new_image}"
log_info " if you want future 'docker compose up' to use this version."
}
# Check for required tools
check_requirements() {
if ! command -v docker &> /dev/null; then
log_error "docker is required but not installed"
exit 1
fi
if ! command -v sed &> /dev/null; then
log_error "sed is required but not installed"
exit 1
fi
if [ ! -f "${NGINX_CONF}" ]; then
log_error "nginx config not found at ${NGINX_CONF}"
exit 1
fi
if [ ! -f "${COMPOSE_FILE}" ]; then
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
}
# Run
check_requirements
main "$@"
+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)"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# Notarize a macOS .app bundle with Apple
# Usage: notarize_mac_app.sh <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euxo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set"
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ==="
xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" \
--wait
# Clean up the zip
rm "$ZIP_PATH"
echo "=== Stapling notarization ticket to app ==="
xcrun stapler staple "$APP_PATH"
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
#
# Warmup Script for Eagle Server
#
# This script warms up the JIT compiler before switching traffic to a new instance.
# It uses the Go warmup tool which:
# 1. Creates a test game via bidirectional streaming
# 2. Posts an Improve command
# 3. Verifies action results and new commands
# 4. Cleans up the test game
#
# Usage: ./warmup-eagle.sh HOST:PORT
#
# Example:
# ./warmup-eagle.sh localhost:40032
# ./warmup-eagle.sh localhost:40034
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
HOST="${1:-localhost:40032}"
log_info "Warming up Eagle server at ${HOST}..."
# Try to find the Go warmup tool
WARMUP_TOOL=""
# Check if we're in the project directory with bazel
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
# Try to find the pre-built binary
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
if [ -x "${BAZEL_BIN}" ]; then
WARMUP_TOOL="${BAZEL_BIN}"
fi
fi
# Check for the warmup tool in common locations (for deployed environments)
if [ -z "${WARMUP_TOOL}" ]; then
for path in \
"${SCRIPT_DIR}/bin/warmup" \
"/opt/eagle0/scripts/bin/warmup" \
"/opt/eagle0/bin/warmup" \
"/usr/local/bin/eagle-warmup" \
"${SCRIPT_DIR}/warmup"; do
if [ -x "${path}" ]; then
WARMUP_TOOL="${path}"
break
fi
done
fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
log_info "Warmup complete!"
exit 0
else
log_error "Go warmup tool failed"
exit 1
fi
fi
# Fallback to grpcurl-based warmup
log_warn "Go warmup tool not found, falling back to grpcurl"
# Check for grpcurl
if ! command -v grpcurl &> /dev/null; then
log_error "Neither Go warmup tool nor grpcurl is available"
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
log_error "Or install grpcurl: brew install grpcurl (macOS)"
exit 1
fi
# Warmup iterations
WARMUP_ITERATIONS=3
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
log_info "Warming up GetRunningGames..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
if echo "$RESULT" | grep -q "games\|{}"; then
echo -n "."
else
log_error "GetRunningGames failed on iteration $i"
exit 1
fi
done
echo " done"
# 2. Call GetSettings - exercises settings loading
log_info "Warming up GetSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "GetSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# 3. Call AddSettings with empty list - exercises settings path
log_info "Warming up AddSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "AddSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# Final health check
log_info "Verifying server health..."
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
log_info "Health check passed"
else
log_error "Health check failed"
exit 1
fi
log_info ""
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
log_info "The JIT should be warmed for:"
log_info " - gRPC layer and protobuf parsing"
log_info " - Settings loading and management"
log_info ""
log_warn "Note: For full warmup including game creation and command processing,"
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
@@ -107,6 +107,23 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
alCache,
battalionTypeGetter,
braveWaterCost));
} else if (!defenderPositions.empty()) {
// Defenders exist but none are on castles - they're scattering/fleeing.
// Chase them down rather than holding empty castles, since eliminating
// all defenders also wins the battle via LAST_PLAYER_STANDING.
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
Occupants(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count()),
gameState->hex_map(),
defenderPositions,
attackerPid,
attackerUnits,
apdCache,
alCache,
battalionTypeGetter,
braveWaterCost));
} else {
chosenStrategy = HoldCastlesStrategy;
}
@@ -108,6 +108,7 @@
<Compile Include="Assets/Eagle/CommandSelectors/FreeForAllDecisionCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerButtonEditor.cs" />
<Compile Include="Assets/Eagle/CommandWarningPanelController.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialModalPanel.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuManager.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoopEditor.cs" />
<Compile Include="Assets/Eagle/BattalionUtils.cs" />
@@ -154,6 +155,7 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialStep.cs" />
<Compile Include="Assets/Auth/AuthClient.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
@@ -205,6 +207,7 @@
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialState.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
<Compile Include="Assets/Bluetooth/DiceVectors.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoop.cs" />
@@ -222,8 +225,10 @@
<Compile Include="Assets/GUI Pro Kit Fantasy RPG/Extensions/UIParticle/UIParticleSystem.cs" />
<Compile Include="Assets/Shardok/SoundManager.cs" />
<Compile Include="Assets/Shardok/HexMesh.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialSequence.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/DiplomacyCommandSelector.cs" />
<Compile Include="Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveBreakAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
@@ -232,6 +237,7 @@
<Compile Include="Assets/Eagle/CustomFileLogger.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationAcceptedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceEventsNotificationGenerator.cs" />
<Compile Include="Assets/Tutorial/TutorialManager.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/FeastCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Animated Icon/AnimatedIconHandler.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/GenericNotificationGenerator.cs" />
@@ -315,6 +321,7 @@
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerContextMenu.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceAmbassadorImprisonedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIcon.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialHintIndicator.cs" />
<Compile Include="Assets/Bluetooth/UnityDieColors.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/OrganizeTroopsCommandSelector.cs" />
<Compile Include="Assets/Shardok/ExtinguishAnimator.cs" />
@@ -363,7 +370,9 @@
<Compile Include="Assets/Terrain Hexes/Example Scene/BasicHexArranger.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ManagePrisonersCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SendSuppliesCommandSelector.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialUIManager.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Window/WindowManager.cs" />
<Compile Include="Assets/Auth/InvitationCodeManager.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroExiledDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SuppressBeastsSucceededNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/ReservesTableController.cs" />
@@ -383,6 +392,7 @@
<Compile Include="Assets/Eagle/Notifications/FactionDestroyedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerNotification.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/HandleCapturedHeroesCommandSelector.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialOverlayController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ARNNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/AttackDecisionCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
@@ -58,10 +58,19 @@ namespace Auth {
/// Routed to Go auth service.
/// </summary>
/// <param name="provider">Discord or Google</param>
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
/// <param name="invitationCode">Optional invitation code for new account
/// registration</param> <returns>Tuple of (URL to open in browser, state token for
/// polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(
OAuthProvider provider,
string invitationCode = null) {
var request = new GetOAuthUrlRequest { Provider = provider };
if (!string.IsNullOrEmpty(invitationCode)) {
request.InvitationCode = invitationCode;
Debug.Log("[AuthClient] Including invitation code in OAuth request");
}
var response = await _authServiceClient.GetOAuthUrlAsync(request);
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
@@ -94,6 +103,10 @@ namespace Auth {
case OAuthStatus.Expired:
throw new Exception("OAuth session expired. Please try again.");
case OAuthStatus.InvitationRequired:
Debug.Log("[AuthClient] Server requires invitation code for new account");
return response; // Return so OAuthManager can handle this
case OAuthStatus.Pending:
// Check timeout
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
@@ -0,0 +1,141 @@
using System;
using System.IO;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages invitation codes for new account registration.
/// Reads codes from installer-provided file or allows manual entry.
/// </summary>
public static class InvitationCodeManager {
private const string InvitationFileName = "invitation.json";
private const string PlayerPrefsKey = "InvitationCode";
// Cache the code to avoid repeated file reads
private static string _cachedCode;
private static bool _cacheInitialized;
/// <summary>
/// Get the invitation code, if available.
/// Checks: 1) Cached value, 2) PlayerPrefs, 3) File from installer
/// </summary>
public static string GetInvitationCode() {
if (_cacheInitialized) { return _cachedCode; }
// Check PlayerPrefs first (manual entry takes priority)
string prefsCode = PlayerPrefs.GetString(PlayerPrefsKey, null);
if (!string.IsNullOrEmpty(prefsCode)) {
_cachedCode = prefsCode;
_cacheInitialized = true;
Debug.Log("[InvitationCodeManager] Using invitation code from PlayerPrefs");
return _cachedCode;
}
// Try to read from installer file
string fileCode = ReadFromFile();
if (!string.IsNullOrEmpty(fileCode)) {
_cachedCode = fileCode;
_cacheInitialized = true;
Debug.Log("[InvitationCodeManager] Using invitation code from installer file");
return _cachedCode;
}
_cacheInitialized = true;
return null;
}
/// <summary>
/// Set invitation code manually (e.g., from UI input).
/// </summary>
public static void SetInvitationCode(string code) {
if (string.IsNullOrEmpty(code)) {
PlayerPrefs.DeleteKey(PlayerPrefsKey);
_cachedCode = null;
} else {
PlayerPrefs.SetString(PlayerPrefsKey, code);
_cachedCode = code;
}
_cacheInitialized = true;
PlayerPrefs.Save();
Debug.Log(
$"[InvitationCodeManager] Invitation code {(string.IsNullOrEmpty(code) ? "cleared" : "set")}");
}
/// <summary>
/// Clear the invitation code after successful account creation.
/// </summary>
public static void ClearInvitationCode() {
PlayerPrefs.DeleteKey(PlayerPrefsKey);
_cachedCode = null;
_cacheInitialized = true;
PlayerPrefs.Save();
// Also delete the installer file if it exists
DeleteInstallerFile();
Debug.Log("[InvitationCodeManager] Invitation code cleared");
}
/// <summary>
/// Check if an invitation code is available.
/// </summary>
public static bool HasInvitationCode => !string.IsNullOrEmpty(GetInvitationCode());
private static string ReadFromFile() {
try {
string installDir = GetInstallDirectory();
if (string.IsNullOrEmpty(installDir)) { return null; }
string filePath = Path.Combine(installDir, InvitationFileName);
if (!File.Exists(filePath)) { return null; }
string json = File.ReadAllText(filePath);
// Simple JSON parsing for {"invitation_code":"XXXX"}
var parsed = JsonUtility.FromJson<InvitationFile>(json);
return parsed?.invitation_code;
} catch (Exception ex) {
Debug.LogWarning(
$"[InvitationCodeManager] Failed to read invitation file: {ex.Message}");
return null;
}
}
private static void DeleteInstallerFile() {
try {
string installDir = GetInstallDirectory();
if (string.IsNullOrEmpty(installDir)) { return; }
string filePath = Path.Combine(installDir, InvitationFileName);
if (File.Exists(filePath)) {
File.Delete(filePath);
Debug.Log("[InvitationCodeManager] Deleted installer invitation file");
}
} catch (Exception ex) {
Debug.LogWarning(
$"[InvitationCodeManager] Failed to delete invitation file: {ex.Message}");
}
}
private static string GetInstallDirectory() {
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
// Windows: %LOCALAPPDATA%\eagle0
string localAppData =
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "eagle0");
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
// Mac: ~/Library/Application Support/eagle0
string appSupport =
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appSupport, "eagle0");
#else
// Other platforms not yet supported
return null;
#endif
}
[Serializable]
private class InvitationFile {
public string invitation_code;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2a19f11d1f82412e8e2811b366113ac
@@ -25,6 +25,7 @@ namespace Auth {
public event Action<string> OnLoginFailed;
public event Action OnLogout;
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
public event Action OnInvitationRequired; // new user needs invitation code
public bool IsAuthenticated => TokenStorage.HasValidToken;
public string DisplayName => TokenStorage.DisplayName;
@@ -86,8 +87,11 @@ namespace Auth {
_currentLoginProvider = provider; // Track for later storage
try {
// Get invitation code if available (for new account registration)
string invitationCode = InvitationCodeManager.GetInvitationCode();
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
@@ -96,6 +100,13 @@ namespace Auth {
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Handle invitation required status
if (response.Status == OAuthStatus.InvitationRequired) {
Debug.Log("[OAuthManager] Server requires invitation code for new account");
OnInvitationRequired?.Invoke();
throw new Exception("An invitation code is required to create a new account");
}
// Store tokens with provider info
var providerName = provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
@@ -106,8 +117,9 @@ namespace Auth {
response.User.DisplayName ?? "",
providerName);
// Notify listeners
// Clear invitation code after successful new account creation
if (response.IsNewUser) {
InvitationCodeManager.ClearInvitationCode();
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
@@ -100,6 +100,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Invitation Code Entry")]
public GameObject invitationCodePanel;
public TMP_InputField invitationCodeField;
public Button submitInvitationCodeButton;
public TextMeshProUGUI invitationCodeErrorText;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
@@ -233,6 +239,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (submitInvitationCodeButton != null) {
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
}
// Subscribe to OAuthManager events
if (OAuthManager.Instance != null) {
@@ -240,6 +249,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
}
// Show auth panel with stored accounts
@@ -247,8 +257,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void ShowAuthPanel() {
// Hide display name panel
// Hide other panels
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
@@ -454,11 +465,53 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
MainQueue.Q.Enqueue(() => {
// Show display name panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(true);
if (displayNameErrorText != null) displayNameErrorText.text = "";
});
}
private void OnInvitationRequired() {
MainQueue.Q.Enqueue(() => {
// Show invitation code entry panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text =
"An invitation code is required to create a new account.";
}
if (invitationCodeField != null) invitationCodeField.text = "";
});
}
private void OnSubmitInvitationCodeClicked() {
if (invitationCodeField == null) return;
var code = invitationCodeField.text.Trim();
if (string.IsNullOrEmpty(code)) {
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Please enter an invitation code";
}
return;
}
// Save the code and return to login screen to retry
InvitationCodeManager.SetInvitationCode(code);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Code saved. Please sign in again.";
}
// Return to auth panel after a short delay
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) {
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
}
ShowAuthPanel();
});
}
private async void OnSetDisplayNameClicked() {
if (displayNameField == null || OAuthManager.Instance == null) return;
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using common;
using common.GUIUtils;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
@@ -158,9 +159,11 @@ namespace eagle {
public void StopAll() {
_newModel = null;
ModelUpdater.StopListeningForUpdates();
ModelUpdater.UpdateAction = null;
ModelUpdater = null;
if (ModelUpdater != null) {
ModelUpdater.StopListeningForUpdates();
ModelUpdater.UpdateAction = null;
ModelUpdater = null;
}
SwapModel();
Model = null;
chronicleCanvasController.Entries = new List<ChronicleEntry>();
@@ -281,6 +284,13 @@ namespace eagle {
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
// Initialize tutorial system
TutorialManager.Instance?.Initialize(this, null);
if (TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
#endif
@@ -362,6 +372,12 @@ namespace eagle {
SetNextActiveProvinceButton();
_dominionPanelController.ForceUpdate();
SetMusic();
// Notify tutorial system of province selection
if (pid.HasValue) {
TutorialManager.Instance?.TriggerRegistry?.OnProvinceSelected(
Model.Provinces[pid.Value]);
}
}
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
@@ -477,6 +493,9 @@ namespace eagle {
var oldModel = Model;
Model = _newModel;
// Notify tutorial system of model change
TutorialManager.Instance?.TriggerRegistry?.OnModelUpdated(Model, oldModel);
provinceInfoPanelController.Model = _newModel;
freeHeroesTableController.Model = _newModel;
movingArmiesTableController.Model = _newModel;
@@ -625,6 +644,9 @@ namespace eagle {
}
private void PostCommittedCommand(ProvinceId provinceId, SelectedCommand selectedCommand) {
// Notify tutorial system of command
TutorialManager.Instance?.TriggerRegistry?.OnCommandIssued(selectedCommand);
ModelUpdater.PostCommand(provinceId: provinceId, command: selectedCommand)
.ContinueWith(response => {
if (response.IsFaulted) { errorHandler.Add(response.Exception); }
@@ -2992,6 +2992,88 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_IsOn: 1
--- !u!1 &16227905
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 16227909}
- component: {fileID: 16227908}
- component: {fileID: 16227907}
- component: {fileID: 16227906}
m_Layer: 0
m_Name: TutorialManager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &16227906
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 16227905}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 370b3269832442e196d1b432bdae2f1e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Eagle0.Tutorial.TutorialTestSetup
EnableTestTutorials: 1
ResetProgressOnStart: 0
--- !u!114 &16227907
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 16227905}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 52abae862492497cb7a3f8c72f837b37, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Eagle0.Tutorial.TutorialUIManager
ModalPanel: {fileID: 0}
OverlayController: {fileID: 0}
HintIndicatorPrefab: {fileID: 0}
TutorialCanvas: {fileID: 0}
UseFallbackUI: 1
FallbackFont: {fileID: 12800000, guid: f0f6814ff8ef048bcbfd5002f77188ea, type: 3}
--- !u!114 &16227908
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 16227905}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c529affa73b04d129ec5826ed0ac75e7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Eagle0.Tutorial.TutorialManager
TutorialsEnabled: 1
OnboardingSequence: {fileID: 0}
UIManager: {fileID: 16227907}
DebugLogging: 0
--- !u!4 &16227909
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 16227905}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 2560, y: 1440, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &17277548
GameObject:
m_ObjectHideFlags: 0
@@ -8532,10 +8614,10 @@ RectTransform:
- {fileID: 1396438488}
m_Father: {fileID: 2130578398}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 333.5, y: -11.255}
m_SizeDelta: {x: 200, y: 20}
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!114 &75920627
MonoBehaviour:
@@ -10927,10 +11009,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2130578398}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 218.5, y: -11.255}
m_SizeDelta: {x: 20, y: 20}
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!114 &103511974
MonoBehaviour:
@@ -12298,10 +12380,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 683322975}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 306.5, y: -40}
m_SizeDelta: {x: 51, y: 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!114 &114306270
MonoBehaviour:
@@ -15614,6 +15696,10 @@ MonoBehaviour:
displayNameField: {fileID: 1956057658}
setDisplayNameButton: {fileID: 700483942}
displayNameErrorText: {fileID: 77360322}
invitationCodePanel: {fileID: 0}
invitationCodeField: {fileID: 0}
submitInvitationCodeButton: {fileID: 0}
invitationCodeErrorText: {fileID: 0}
connectionStatusText: {fileID: 77360322}
connectionPanel: {fileID: 596196018}
gameSelectionPanel: {fileID: 1102342355}
@@ -20624,10 +20710,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -32.504997}
m_SizeDelta: {x: 492, y: 45.01}
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!114 &171635196
MonoBehaviour:
@@ -29397,7 +29483,7 @@ RectTransform:
m_Father: {fileID: 208702977}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 10, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -31829,7 +31915,7 @@ RectTransform:
m_Father: {fileID: 1396438488}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 20, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -33967,6 +34053,7 @@ MonoBehaviour:
eagleGameController: {fileID: 1965467940}
ShardokCanvas: {fileID: 2088078267}
connectionHandler: {fileID: 135486720}
resetTutorialsButton: {fileID: 0}
--- !u!1 &305665272
GameObject:
m_ObjectHideFlags: 0
@@ -36778,10 +36865,10 @@ RectTransform:
- {fileID: 1666727525}
m_Father: {fileID: 2039520752}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 321, y: -11.255}
m_SizeDelta: {x: 200, y: 20}
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!114 &329919723
MonoBehaviour:
@@ -47131,8 +47218,8 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1666727525}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.1, y: 0}
m_AnchorMax: {x: 0.1, y: 1}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 20, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -64015,10 +64102,10 @@ RectTransform:
- {fileID: 1375685348}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -215.03}
m_SizeDelta: {x: 492, y: 30}
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!114 &548637657
MonoBehaviour:
@@ -71487,10 +71574,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 10, y: -120.03}
m_SizeDelta: {x: 0, y: 10}
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!114 &590819392
MonoBehaviour:
@@ -74595,6 +74682,146 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 624827069}
m_CullTransparentMesh: 0
--- !u!1 &625062879
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 625062880}
- component: {fileID: 625062882}
- component: {fileID: 625062881}
m_Layer: 0
m_Name: Open Resources
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &625062880
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 625062879}
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: 673182551}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &625062881
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 625062879}
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: 'Reset Tutorials
'
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: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, 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_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 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!222 &625062882
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 625062879}
m_CullTransparentMesh: 1
--- !u!1 &625252446
GameObject:
m_ObjectHideFlags: 0
@@ -82452,6 +82679,160 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 673008387}
m_CullTransparentMesh: 0
--- !u!1 &673182550
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 673182551}
- component: {fileID: 673182555}
- component: {fileID: 673182554}
- component: {fileID: 673182553}
- component: {fileID: 673182552}
m_Layer: 0
m_Name: Reset Tutorials Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &673182551
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 673182550}
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: 625062880}
m_Father: {fileID: 1566150938}
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!114 &673182552
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 673182550}
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 &673182553
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 673182550}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 673182554}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 300036928}
m_TargetAssemblyTypeName: SettingsPanelController, Assembly-CSharp
m_MethodName: OnResetTutorialsClick
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &673182554
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 673182550}
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: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
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!222 &673182555
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 673182550}
m_CullTransparentMesh: 1
--- !u!1 &673307124
GameObject:
m_ObjectHideFlags: 0
@@ -83590,10 +83971,10 @@ RectTransform:
- {fileID: 1119022577}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -354}
m_SizeDelta: {x: 492, y: 40}
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!114 &683322976
MonoBehaviour:
@@ -93480,7 +93861,7 @@ GameObject:
- component: {fileID: 749366808}
- component: {fileID: 749366807}
m_Layer: 0
m_Name: Button
m_Name: Open Resources Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -93501,10 +93882,10 @@ RectTransform:
- {fileID: 392813076}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -250.03}
m_SizeDelta: {x: 492, y: 30}
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!114 &749366807
MonoBehaviour:
@@ -95360,10 +95741,10 @@ RectTransform:
- {fileID: 699949417}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -180.03}
m_SizeDelta: {x: 492, y: 30}
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!114 &770361456
MonoBehaviour:
@@ -96170,10 +96551,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2039520752}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 208.5, y: -11.255}
m_SizeDelta: {x: 15, y: 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!114 &779503534
MonoBehaviour:
@@ -99264,10 +99645,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2039520752}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 446, y: -11.255}
m_SizeDelta: {x: 40, y: 15.01}
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!114 &806086202
MonoBehaviour:
@@ -99806,10 +100187,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 10, y: -313.27002}
m_SizeDelta: {x: 0, y: 31.459991}
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!114 &808870206
MonoBehaviour:
@@ -105263,10 +105644,10 @@ RectTransform:
- {fileID: 113544276}
m_Father: {fileID: 1590704177}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 333.5, y: -11.255}
m_SizeDelta: {x: 200, y: 20}
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!114 &858025936
MonoBehaviour:
@@ -121385,10 +121766,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1590704177}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 196, y: -11.255}
m_SizeDelta: {x: 15, y: 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!114 &972401127
MonoBehaviour:
@@ -122506,7 +122887,7 @@ RectTransform:
m_Father: {fileID: 113544276}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 20, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -123520,7 +123901,7 @@ RectTransform:
m_Father: {fileID: 1425392451}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0.1, y: 1}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 10, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -140643,10 +141024,10 @@ RectTransform:
- {fileID: 717768291}
m_Father: {fileID: 683322975}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 417, y: -20}
m_SizeDelta: {x: 150, y: 40}
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!114 &1119022578
MonoBehaviour:
@@ -147583,10 +147964,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 683322975}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 135.5, y: -40}
m_SizeDelta: {x: 51, y: 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!114 &1184808158
MonoBehaviour:
@@ -153109,10 +153490,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2130578398}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 108.5, y: -11.255}
m_SizeDelta: {x: 150, y: 22.51}
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!114 &1219993393
MonoBehaviour:
@@ -174192,10 +174573,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2130578398}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 196, y: -11.255}
m_SizeDelta: {x: 15, y: 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!114 &1390131026
MonoBehaviour:
@@ -178285,10 +178666,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2039520752}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 111, y: -11.255}
m_SizeDelta: {x: 170, y: 22.51}
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!114 &1415802613
MonoBehaviour:
@@ -178444,10 +178825,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 2130578398}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 448.5, y: -11.255}
m_SizeDelta: {x: 20, y: 20}
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!114 &1415887397
MonoBehaviour:
@@ -181904,10 +182285,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1590704177}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 108.5, y: -11.255}
m_SizeDelta: {x: 150, y: 22.51}
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!114 &1448655991
MonoBehaviour:
@@ -196899,10 +197280,10 @@ RectTransform:
- {fileID: 1122913432}
m_Father: {fileID: 683322975}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -20}
m_SizeDelta: {x: 100, y: 40}
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!114 &1557328375
MonoBehaviour:
@@ -198649,7 +199030,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 2147483647
m_IsActive: 0
m_IsActive: 1
--- !u!224 &1566150938
RectTransform:
m_ObjectHideFlags: 0
@@ -198670,6 +199051,7 @@ RectTransform:
- {fileID: 770361455}
- {fileID: 548637656}
- {fileID: 749366806}
- {fileID: 673182551}
- {fileID: 2039520752}
- {fileID: 808870205}
- {fileID: 683322975}
@@ -200241,10 +200623,10 @@ RectTransform:
- {fileID: 2123746368}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -98.774994}
m_SizeDelta: {x: 492, y: 22.51}
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!114 &1590704178
MonoBehaviour:
@@ -219035,10 +219417,10 @@ RectTransform:
- {fileID: 543540660}
m_Father: {fileID: 683322975}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 221, y: -20}
m_SizeDelta: {x: 100, y: 40}
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!114 &1775111780
MonoBehaviour:
@@ -221290,7 +221672,7 @@ RectTransform:
m_Father: {fileID: 1110049512}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 10, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -247406,10 +247788,10 @@ RectTransform:
- {fileID: 806086201}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -281.285}
m_SizeDelta: {x: 492, y: 22.51}
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!114 &2039520753
MonoBehaviour:
@@ -252578,10 +252960,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1590704177}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 218.5, y: -11.255}
m_SizeDelta: {x: 20, y: 20}
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!114 &2096030393
MonoBehaviour:
@@ -255937,7 +256319,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &2109805906
RectTransform:
m_ObjectHideFlags: 0
@@ -255957,7 +256339,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -145.03}
m_AnchoredPosition: {x: 256, y: -142.85143}
m_SizeDelta: {x: 492, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2109805907
@@ -257369,10 +257751,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 1590704177}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 448.5, y: -11.255}
m_SizeDelta: {x: 20, y: 20}
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!114 &2123746369
MonoBehaviour:
@@ -258672,10 +259054,10 @@ RectTransform:
- {fileID: 1415887396}
m_Father: {fileID: 1566150938}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 256, y: -71.265}
m_SizeDelta: {x: 492, y: 22.51}
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!114 &2130578399
MonoBehaviour:
@@ -268781,6 +269163,7 @@ SceneRoots:
m_Roots:
- {fileID: 279614944}
- {fileID: 815907755}
- {fileID: 16227909}
- {fileID: 122332320}
- {fileID: 682560790}
- {fileID: 1342510189}
@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO;
using common;
using eagle;
using Eagle0.Tutorial;
using Net.Eagle0.Shardok.Api;
using Shardok;
using TMPro;
@@ -49,6 +50,8 @@ public class SettingsPanelController : MonoBehaviour {
public GameObject ShardokCanvas;
public ConnectionHandler connectionHandler;
public Button resetTutorialsButton;
void Start() {
_active = false;
panel.SetActive(false);
@@ -148,6 +151,11 @@ public class SettingsPanelController : MonoBehaviour {
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
}
public void OnResetTutorialsClick() {
TutorialManager.Instance?.ResetAllProgress();
UnityEngine.Debug.Log("Tutorial progress reset");
}
private void HandleLobby() {
eagleGameController.StopAll();
eagleGameController.gameObject.SetActive(false);
@@ -68,14 +68,10 @@ namespace Shardok {
return;
}
Debug.Log(
$"ArrowVolleyAnimator: Starting volley from cell {sourceCellIndex} to {targetCellIndex}");
StartCoroutine(SpawnArrowVolley(sourcePos.Value, targetPos.Value));
}
private IEnumerator SpawnArrowVolley(Vector2 sourcePosition, Vector2 targetPosition) {
Debug.Log(
$"ArrowVolleyAnimator: Spawning {arrowCount} arrows from {sourcePosition} to {targetPosition}");
var arrows = new List<GameObject>();
for (int i = 0; i < arrowCount; i++) {
@@ -59,7 +59,7 @@ namespace Shardok {
return;
}
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
@@ -462,11 +462,14 @@ public class HexGrid : MonoBehaviour {
}
public void SetProfessionImage(int cellIndex, Texture image) {
var professionImage = cells[cellIndex].ProfessionImage;
if (professionImage == null) return;
if (image == null) {
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: false);
professionImage.gameObject.SetActive(value: false);
} else {
cells[cellIndex].ProfessionImage.texture = image;
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: true);
professionImage.texture = image;
professionImage.gameObject.SetActive(value: true);
}
}
@@ -475,11 +478,14 @@ public class HexGrid : MonoBehaviour {
}
public void SetUnitTypeImage(int cellIndex, Texture image) {
var unitTypeImage = cells[cellIndex].UnitTypeImage;
if (unitTypeImage == null) return;
if (image == null) {
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: false);
unitTypeImage.gameObject.SetActive(value: false);
} else {
cells[cellIndex].UnitTypeImage.texture = image;
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: true);
unitTypeImage.texture = image;
unitTypeImage.gameObject.SetActive(value: true);
}
}
@@ -96,9 +96,22 @@ namespace Shardok {
private HexGrid __hexGrid;
private Coroutine _activeAnimation;
private GameObject _attackerWeapon;
private GameObject _defenderWeapon;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
private void CleanupWeapons() {
if (_attackerWeapon != null) {
Destroy(_attackerWeapon);
_attackerWeapon = null;
}
if (_defenderWeapon != null) {
Destroy(_defenderWeapon);
_defenderWeapon = null;
}
}
/// <summary>
/// Starts a melee animation between two adjacent hex cells.
/// </summary>
@@ -122,8 +135,8 @@ namespace Shardok {
return;
}
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
if (attackerPos == null || defenderPos == null) {
Debug.LogWarning(
@@ -131,10 +144,11 @@ namespace Shardok {
return;
}
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
if (_activeAnimation != null) {
StopCoroutine(_activeAnimation);
CleanupWeapons();
}
Debug.Log(
$"MeleeAnimator: Starting melee from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
_activeAnimation = StartCoroutine(
AnimateClash(attackerPos.Value, defenderPos.Value, attackerType, defenderType));
}
@@ -232,9 +246,9 @@ namespace Shardok {
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
// Create weapons with type-specific scaling
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
// Create weapons with type-specific scaling (stored in class fields for cleanup)
_attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
_defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
bool attackerSwings = attackerConfig.swings;
bool defenderSwings = defenderConfig.swings;
@@ -259,8 +273,8 @@ namespace Shardok {
midpoint.y + direction.y * (weaponOffset - thrustDistance),
10f);
attackerWeapon.transform.localPosition = attackerStart;
defenderWeapon.transform.localPosition = defenderStart;
_attackerWeapon.transform.localPosition = attackerStart;
_defenderWeapon.transform.localPosition = defenderStart;
float attackerBaseAngle = baseAngle + attackerConfig.rotationOffset;
float defenderBaseAngle = baseAngle + 180f + defenderConfig.rotationOffset;
@@ -272,7 +286,7 @@ namespace Shardok {
// Swing toward clash point
yield return StartCoroutine(AnimateWeaponSwing(
attackerWeapon,
_attackerWeapon,
attackerStart,
attackerClash,
attackerBaseAngle,
@@ -282,7 +296,7 @@ namespace Shardok {
true));
yield return StartCoroutine(AnimateWeaponSwing(
defenderWeapon,
_defenderWeapon,
defenderStart,
defenderClash,
defenderBaseAngle,
@@ -293,8 +307,8 @@ namespace Shardok {
// Brief shake at impact
yield return StartCoroutine(ImpactShake(
attackerWeapon,
defenderWeapon,
_attackerWeapon,
_defenderWeapon,
attackerClash,
defenderClash,
0.05f,
@@ -302,7 +316,7 @@ namespace Shardok {
// Swing back
yield return StartCoroutine(AnimateWeaponSwing(
attackerWeapon,
_attackerWeapon,
attackerClash,
attackerStart,
attackerBaseAngle,
@@ -312,7 +326,7 @@ namespace Shardok {
false));
yield return StartCoroutine(AnimateWeaponSwing(
defenderWeapon,
_defenderWeapon,
defenderClash,
defenderStart,
defenderBaseAngle,
@@ -323,8 +337,7 @@ namespace Shardok {
}
// Cleanup
if (attackerWeapon != null) { Destroy(attackerWeapon); }
if (defenderWeapon != null) { Destroy(defenderWeapon); }
CleanupWeapons();
_activeAnimation = null;
}
@@ -418,6 +431,7 @@ namespace Shardok {
StopCoroutine(_activeAnimation);
_activeAnimation = null;
}
CleanupWeapons();
}
}
}
@@ -65,8 +65,8 @@ namespace Shardok {
return;
}
Vector2? sourcePos = _hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellLocalPosition(targetCellIndex);
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using common;
using eagle0;
using Eagle0.Tutorial;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using TMPro;
@@ -244,6 +245,8 @@ namespace Shardok {
turnHistoryPanel.Clear();
this.gameObject.SetActive(false);
} else {
// Notify tutorial system of turn end
TutorialManager.Instance?.TriggerRegistry?.OnTurnEnded();
Model.EndTurn();
}
}
@@ -394,8 +397,6 @@ namespace Shardok {
Model = shardokGameModel;
Model.UpdateAction = ModelUpdated;
HexMap map = Model.Map;
Texture[] textures = new Texture[map.RowCount * map.ColumnCount];
Coords coords = new Coords();
@@ -421,10 +422,17 @@ namespace Shardok {
hexGrid.SetUp();
hexGrid.gameObject.SetActive(true);
// Set UpdateAction after hexGrid is ready to avoid race condition
Model.UpdateAction = ModelUpdated;
SetHeroLabels();
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdated(); });
// Initialize tutorial system for tactical combat
TutorialManager.Instance?.Initialize(null, this);
TutorialManager.Instance?.TriggerRegistry?.OnBattleEntered(Model);
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
#endif
@@ -544,6 +552,9 @@ namespace Shardok {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
// Notify tutorial system of battle action
TutorialManager.Instance?.TriggerRegistry?.OnBattleAction(historyEntry);
ActionType type = historyEntry.Type;
AnimationType animationType = AnimationTypeForAction(type);
@@ -568,7 +579,12 @@ namespace Shardok {
if (Model.UnitsById.TryGetValue(
historyEntry.Actor.Value,
out var actorUnit)) {
int sourceIndex = MapCoordsToGridIndex(actorUnit.Location);
// For Move actions, use stored source coords (unit's pre-move location)
// For other actions, use current unit location
int sourceIndex =
Model.MoveSourceCoords.TryGetValue(i, out var sourceCoords)
? MapCoordsToGridIndex(sourceCoords)
: MapCoordsToGridIndex(actorUnit.Location);
// Get target - either coords (archery) or unit location (melee)
int targetIndex = -1;
@@ -1181,6 +1197,9 @@ namespace Shardok {
}
_selectedGridIndex = selectedIndex;
RedrawCommandOverlays(selectedIndex, selectedIndex);
// Notify tutorial system of unit selection
TutorialManager.Instance?.TriggerRegistry?.OnUnitSelected(selectedIndex);
}
public void HandleActionClick(int clickedIndex) {
@@ -75,6 +75,13 @@ public class ShardokGameModel {
ReserveUnitsById.Values.FirstOrDefault(u => u.Location.Equals(location));
public List<ActionResultView> History { get; private set; }
/// <summary>
/// Tracks the source coordinates for Move actions, keyed by history index.
/// Used for animations since the model is fully updated before animations play.
/// </summary>
public Dictionary<int, Coords> MoveSourceCoords { get; } = new();
public PlayerId PlayerId { get; private set; }
public GameStatus GameStatus { get; private set; }
public RoundId CurrentRound { get; private set; }
@@ -402,6 +409,14 @@ public class ShardokGameModel {
}
private void HandleNewHistoryEntry(ActionResultView entry) {
// For Move actions, record the source coordinates BEFORE applying the diff
// This is needed for animations since all diffs are applied before animations play
if (entry.Type == ActionType.Move && entry.Actor != null) {
if (UnitsById.TryGetValue(entry.Actor.Value, out var actorUnit)) {
MoveSourceCoords[History.Count] = actorUnit.Location;
}
}
History.Add(entry);
var gsvDiff = entry.GameStateViewDiff;
if (gsvDiff == null) {
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0deea4d267414bcd9421d74e9c1d0d7e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f80e1b28dcb84478b8a97821d82dbde7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace Eagle0.Tutorial {
/// <summary>
/// Ordered collection of tutorial steps forming a complete tutorial.
/// Can be used for onboarding or contextual tutorials.
/// Create instances via Assets > Create > Eagle0 > Tutorial Sequence.
/// </summary>
[CreateAssetMenu(fileName = "TutorialSequence", menuName = "Eagle0/Tutorial Sequence")]
public class TutorialSequence : ScriptableObject {
[Header("Identity")]
[Tooltip("Unique identifier for this tutorial sequence")]
public string SequenceId;
[Tooltip("Display name shown to user")]
public string DisplayName;
[Tooltip("Whether this is the main onboarding sequence")]
public bool IsOnboarding;
[Header("Steps")]
[Tooltip("Ordered list of tutorial steps")]
public List<TutorialStep> Steps = new List<TutorialStep>();
[Header("Prerequisites")]
[Tooltip("Tutorial IDs that must be completed before this one can trigger")]
public List<string> RequiredCompletedTutorials = new List<string>();
[Header("Events")]
[Tooltip("Called when this sequence starts")]
public UnityEvent OnSequenceStart;
[Tooltip("Called when this sequence completes normally")]
public UnityEvent OnSequenceComplete;
[Tooltip("Called when user skips this sequence")]
public UnityEvent OnSequenceSkipped;
/// <summary>
/// Gets a step by index, or null if out of range.
/// </summary>
public TutorialStep GetStep(int index) {
if (index < 0 || index >= Steps.Count) return null;
return Steps[index];
}
/// <summary>
/// Gets the index of a step by its ID, or -1 if not found.
/// </summary>
public int IndexOfStep(string stepId) {
if (string.IsNullOrEmpty(stepId)) return -1;
for (int i = 0; i < Steps.Count; i++) {
if (Steps[i].StepId == stepId) return i;
}
return -1;
}
/// <summary>
/// Total number of steps in this sequence.
/// </summary>
public int StepCount => Steps?.Count ?? 0;
/// <summary>
/// Checks if all prerequisites are met.
/// </summary>
public bool ArePrerequisitesMet(TutorialState state) {
if (RequiredCompletedTutorials == null || RequiredCompletedTutorials.Count == 0) {
return true;
}
foreach (var reqId in RequiredCompletedTutorials) {
if (!state.HasCompletedTutorial(reqId)) { return false; }
}
return true;
}
#if UNITY_EDITOR
/// <summary>
/// Editor validation to catch common issues.
/// </summary>
private void OnValidate() {
// Ensure SequenceId is set
if (string.IsNullOrEmpty(SequenceId)) { SequenceId = name; }
// Check for duplicate step IDs
var seenIds = new HashSet<string>();
foreach (var step in Steps) {
if (!string.IsNullOrEmpty(step.StepId)) {
if (!seenIds.Add(step.StepId)) {
Debug.LogWarning(
$"TutorialSequence '{SequenceId}': Duplicate step ID '{step.StepId}'");
}
}
}
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 97da9ce2a5d7418998c026d559ad0a13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,103 @@
using System;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// How the tutorial step should be displayed to the user.
/// </summary>
public enum TutorialDisplayMode {
/// <summary>Full modal window blocking game interaction (for important concepts).</summary>
Modal,
/// <summary>Semi-transparent overlay highlighting a specific UI element.</summary>
Overlay,
/// <summary>Small tooltip positioned near target element.</summary>
Tooltip,
/// <summary>Subtle pulsing indicator only (no text popup).</summary>
Hint,
/// <summary>Invisible step for waiting on game events.</summary>
None
}
/// <summary>
/// How the tutorial step is completed/advanced.
/// </summary>
public enum TutorialCompletionType {
/// <summary>User clicks Continue/Got it button.</summary>
ButtonClick,
/// <summary>Specific game event triggers completion.</summary>
GameEvent,
/// <summary>User interacts with the highlighted UI element.</summary>
UIInteraction,
/// <summary>Auto-advances after a delay.</summary>
Timer,
/// <summary>Custom condition checked each frame.</summary>
Condition
}
/// <summary>
/// Single step within a tutorial sequence.
/// Supports multiple display modes and completion conditions.
/// </summary>
[Serializable]
public class TutorialStep {
[Header("Identity")]
[Tooltip("Unique identifier for this step")]
public string StepId;
[Header("Display Content")]
[Tooltip("Title shown in modal or tooltip")]
public string Title;
[Tooltip("Main description text")]
[TextArea(3, 10)]
public string Description;
[Tooltip("Optional icon/image to display")]
public Sprite Icon;
[Tooltip("How this step should be displayed")]
public TutorialDisplayMode DisplayMode = TutorialDisplayMode.Modal;
[Header("UI Targeting")]
[Tooltip("Path to GameObject to highlight (e.g., 'Canvas/CommandPanel/MarchButton')")]
public string TargetGameObjectPath;
[Tooltip("Offset from target element for tooltip/arrow positioning")]
public Vector2 HighlightOffset;
[Tooltip("Whether the highlight should pulse")]
public bool HighlightPulsing = true;
[Header("Completion")]
[Tooltip("How this step is completed")]
public TutorialCompletionType CompletionType = TutorialCompletionType.ButtonClick;
[Tooltip("Event ID to wait for (when CompletionType is GameEvent)")]
public string CompletionEventId;
[Tooltip("Delay in seconds before auto-advance (when CompletionType is Timer)")]
public float AutoAdvanceDelay;
[Header("Flow Control")]
[Tooltip("Whether user can skip this step")]
public bool AllowSkip = true;
[Tooltip("Jump to specific step ID instead of sequential (empty = next step)")]
public string NextStepOverride;
[Header("Audio")]
[Tooltip("Optional narration audio")]
public AudioClip NarrationClip;
[Tooltip("Optional sound effect when step appears")]
public AudioClip EffectClip;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ad37a68e71e24b6a88ace636f74020dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,139 @@
# Tutorial System for Eagle0
## Overview
A modular tutorial system for the Eagle0 Unity client supporting:
- **Onboarding**: Full guided playthrough of first several turns
- **Contextual tutorials**: First-encounter and first-attempt triggers with subtle hints
- **Mixed UI**: Modals for concepts, overlays for UI guidance, hint indicators
- **Local persistence**: PlayerPrefs-based state tracking
---
## Current Status
### Completed
- [x] **Phase 1: Foundation** - TutorialState, TutorialManager, TutorialStep/Sequence
- [x] **Phase 2: UI (partial)** - IMGUI fallback modal with Stoke font
- [x] **Phase 3: Triggers (partial)** - Basic trigger system with hooks in game controllers
- [x] **Test setup** - TutorialTestSetup for province selection, battle entry, command triggers
- [x] **Settings integration** - Reset Tutorials button in Settings panel
### Remaining
- [ ] **Canvas UI prefabs** - Replace IMGUI fallback with styled Canvas-based modal
- [ ] **Overlay system** - TutorialOverlayController for highlighting UI elements
- [ ] **Hint indicators** - TutorialHintIndicator for subtle pulsing dots
- [ ] **Real tutorial content** - Replace test tutorials with actual onboarding sequence
- [ ] **More contextual triggers** - Diplomacy, hero recruitment, spells, terrain, etc.
---
## Architecture
```
TutorialManager (Singleton, DontDestroyOnLoad)
├── TutorialState (PlayerPrefs persistence)
├── TutorialTriggerRegistry
│ ├── OnboardingTriggers
│ └── ContextualTriggers
└── TutorialUIManager
├── TutorialModalPanel (TODO)
├── TutorialOverlayController (TODO)
├── TutorialHintIndicator (TODO)
└── IMGUI Fallback (working)
```
---
## File Structure (Current)
```
Assets/Tutorial/
├── TutorialManager.cs ✓ Singleton, coordinates everything
├── TutorialState.cs ✓ PlayerPrefs persistence
├── TutorialTestSetup.cs ✓ Test component for validation
├── Content/
│ ├── TutorialStep.cs ✓ Step data structure
│ └── TutorialSequence.cs ✓ Sequence ScriptableObject
├── Triggers/
│ └── TutorialTriggerRegistry.cs ✓ Event routing
└── UI/
├── TutorialUIManager.cs ✓ UI coordination + IMGUI fallback
├── TutorialModalPanel.cs ✓ Stub (needs Canvas implementation)
├── TutorialOverlayController.cs ✓ Stub
└── TutorialHintIndicator.cs ✓ Stub
```
---
## Integration Points (Implemented)
### EagleGameController.cs
- `SetUpGame()`: Initializes TutorialManager, starts onboarding
- `SwapModel()`: Calls `OnModelUpdated()`
- `ProvinceWasSelected()`: Calls `OnProvinceSelected()`
- `PostCommittedCommand()`: Calls `OnCommandIssued()`
### ShardokGameController.cs
- `SetUpGame()`: Initializes TutorialManager for battle
- `ModelUpdated()`: Calls `OnBattleAction()`
- `OnTurnEnded()`: Calls `OnTurnEnded()`
- Unit selection: Calls `OnUnitSelected()`
### SettingsPanelController.cs
- Reset Tutorials button: Calls `TutorialManager.Instance.ResetAllProgress()`
---
## Onboarding Flow (Planned)
| Step | Type | Content | Completion |
|------|------|---------|------------|
| 1 | Modal | Welcome to Eagle0 | Button click |
| 2 | Overlay | Map overview - "Select a province" | Province selected |
| 3 | Overlay | Province info panel explanation | Button click |
| 4 | Overlay | Command buttons - "Try March" | March issued |
| 5 | Modal | Turn cycle explanation | Button click |
| 6 | Hidden | Wait for battle available | Model update |
| 7 | Modal | Battle introduction | Button click |
| 8 | Overlay | "Battle!" button highlight | Battle entered |
| 9 | Modal | Tactical combat overview | Button click |
| 10 | Overlay | Select and move a unit | Move issued |
| 11 | Overlay | Attack an enemy | Attack issued |
| 12 | Overlay | End Turn button | Turn ended |
| 13 | Modal | Onboarding complete! | Button click |
---
## Contextual Tutorials (Planned)
### Strategic (First Encounter)
| ID | Trigger | Display |
|----|---------|---------|
| `diplomacy_offer` | First diplomacy command available | Modal |
| `hero_recruitment` | First recruitment available | Modal |
| `province_riot` | First riot in owned province | Modal |
| `weather_control` | First weather command available | Overlay |
| `prisoner_capture` | First prisoner captured | Modal |
### Tactical (First Encounter/Attempt)
| ID | Trigger | Display |
|----|---------|---------|
| `spell_lightning` | Lightning spell available | Tooltip |
| `spell_meteor` | Meteor spell available | Modal |
| `spell_holywave` | Holy Wave available | Tooltip |
| `spell_raisedead` | Raise Dead available | Modal |
| `terrain_fire` | Fire hex encountered | Tooltip |
| `terrain_water` | Water adjacent to unit | Tooltip |
| `ability_charge` | Charge command available | Overlay |
| `flanking` | Flanking opportunity | Hint |
---
## Testing
1. Add `TutorialTestSetup` component to TutorialManager GameObject
2. Assign `Stoke-Regular.ttf` to `FallbackFont` on TutorialUIManager
3. Enable `Debug Logging` on TutorialManager for console output
4. Play game → select province → see test tutorial modal
5. Use Settings → Reset Tutorials to test again
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6e2fbfb348eb443cb91395b3712870da
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using eagle;
using Shardok;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Registry of tutorial triggers that respond to game events.
/// Integrates with game controllers to detect first-encounter situations.
/// </summary>
public class TutorialTriggerRegistry {
private readonly TutorialManager _manager;
private EagleGameController _eagleController;
private ShardokGameController _shardokController;
// Registered contextual tutorials by ID
private Dictionary<string, TutorialSequence> _tutorialSequences =
new Dictionary<string, TutorialSequence>();
// Event-to-tutorial mapping
private Dictionary<string, List<string>> _eventTriggers =
new Dictionary<string, List<string>>();
public TutorialTriggerRegistry(TutorialManager manager) { _manager = manager; }
/// <summary>
/// Initialize with game controller references.
/// </summary>
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
if (eagle != null) { _eagleController = eagle; }
if (shardok != null) { _shardokController = shardok; }
}
/// <summary>
/// Registers a tutorial sequence that can be triggered by events.
/// </summary>
public void RegisterTutorial(TutorialSequence sequence, params string[] triggerEvents) {
if (sequence == null || string.IsNullOrEmpty(sequence.SequenceId)) return;
_tutorialSequences[sequence.SequenceId] = sequence;
foreach (var eventId in triggerEvents) {
if (!_eventTriggers.ContainsKey(eventId)) {
_eventTriggers[eventId] = new List<string>();
}
_eventTriggers[eventId].Add(sequence.SequenceId);
}
Debug.Log(
$"TutorialTriggerRegistry: Registered '{sequence.SequenceId}' for events: {string.Join(", ", triggerEvents)}");
}
/// <summary>
/// Gets a tutorial sequence by ID.
/// </summary>
public TutorialSequence GetSequenceById(string tutorialId) {
_tutorialSequences.TryGetValue(tutorialId, out var sequence);
return sequence;
}
/// <summary>
/// Called when a game event occurs.
/// Checks for tutorials that should be triggered.
/// </summary>
public void OnGameEvent(string eventId, object context = null) {
if (!_eventTriggers.TryGetValue(eventId, out var tutorialIds)) return;
foreach (var tutorialId in tutorialIds) {
if (_manager.State.HasCompletedTutorial(tutorialId)) continue;
var sequence = GetSequenceById(tutorialId);
if (sequence != null && sequence.ArePrerequisitesMet(_manager.State)) {
_manager.TriggerContextualTutorial(tutorialId);
break; // Only trigger one tutorial per event
}
}
}
// ========== Strategic Layer Events ==========
/// <summary>
/// Called when the game model is updated.
/// </summary>
public void OnModelUpdated(IGameModel model, IGameModel previousModel) {
if (model == null) return;
// Check for first battle available
if (model.RunningShardokGameModels?.Count > 0) {
if (previousModel?.RunningShardokGameModels == null ||
previousModel.RunningShardokGameModels.Count == 0) {
OnGameEvent("first_battle_available");
}
}
// Check for diplomacy offers
// Check for riots
// Check for hero recruitment opportunities
// etc. - These would check model state changes
}
/// <summary>
/// Called when a province is selected.
/// </summary>
public void OnProvinceSelected(object province) {
// Track first province selection for onboarding
OnGameEvent("province_selected", province);
}
/// <summary>
/// Called when a command is issued.
/// </summary>
public void OnCommandIssued(object command) {
// Track first command for onboarding
OnGameEvent("command_issued", command);
// Could also check command type for specific tutorials
// e.g., first diplomacy command, first weather control, etc.
}
// ========== Tactical Layer Events ==========
/// <summary>
/// Called when entering a battle.
/// </summary>
public void OnBattleEntered(object battleModel) {
OnGameEvent("battle_entered", battleModel);
}
/// <summary>
/// Called when a battle action result is received.
/// </summary>
public void OnBattleAction(object actionResult) {
OnGameEvent("battle_action", actionResult);
// Check for specific action types that need tutorials
// e.g., first spell cast, first charge, etc.
}
/// <summary>
/// Called when a unit is selected in tactical view.
/// </summary>
public void OnUnitSelected(int cellIndex) { OnGameEvent("unit_selected", cellIndex); }
/// <summary>
/// Called when turn ends in tactical combat.
/// </summary>
public void OnTurnEnded() { OnGameEvent("turn_ended"); }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23dab249a548427091dc94baadedcebd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,374 @@
using System;
using System.Collections;
using eagle;
using Shardok;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Central singleton managing tutorial state, triggers, and UI display.
/// Hooks into EagleGameController and ShardokGameController for game events.
/// </summary>
public class TutorialManager : MonoBehaviour {
public static TutorialManager Instance { get; private set; }
[Header("Configuration")]
[Tooltip("Whether tutorials are enabled")]
public bool TutorialsEnabled = true;
[Tooltip("The main onboarding tutorial sequence")]
public TutorialSequence OnboardingSequence;
[Header("UI References")]
[Tooltip("Reference to the tutorial UI manager")]
public TutorialUIManager UIManager;
[Header("Debug")]
[Tooltip("Enable verbose logging")]
public bool DebugLogging;
// State
private TutorialState _state;
public TutorialState State => _state;
// Trigger registry
private TutorialTriggerRegistry _triggerRegistry;
public TutorialTriggerRegistry TriggerRegistry => _triggerRegistry;
// Active sequence tracking
private TutorialSequence _activeSequence;
private int _currentStepIndex;
private Coroutine _activeStepCoroutine;
/// <summary>
/// Whether we're currently in an onboarding sequence.
/// </summary>
public bool IsOnboarding => _activeSequence != null && _activeSequence.IsOnboarding;
/// <summary>
/// Whether any tutorial sequence is currently active.
/// </summary>
public bool IsSequenceActive => _activeSequence != null;
/// <summary>
/// Current step being displayed, or null if none.
/// </summary>
public TutorialStep CurrentStep => _activeSequence?.GetStep(_currentStepIndex);
private void Awake() {
// Singleton setup
if (Instance != null && Instance != this) {
Debug.LogWarning("TutorialManager: Duplicate instance destroyed");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// Initialize state
_state = TutorialState.Load();
_triggerRegistry = new TutorialTriggerRegistry(this);
Log("TutorialManager initialized");
}
private void OnDestroy() {
if (Instance == this) { Instance = null; }
}
/// <summary>
/// Initialize with game controller references.
/// Call from EagleGameController.SetUpGame() and ShardokGameController.SetUpGame().
/// </summary>
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
_triggerRegistry?.Initialize(eagle, shardok);
Log($"TutorialManager initialized with controllers - Eagle: {eagle != null}, Shardok: {shardok != null}");
}
/// <summary>
/// Starts the main onboarding tutorial sequence.
/// </summary>
public void StartOnboarding() {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) {
Log("StartOnboarding: Tutorials disabled, skipping");
return;
}
if (_state.OnboardingCompleted) {
Log("StartOnboarding: Already completed, skipping");
return;
}
if (OnboardingSequence == null) {
Log("StartOnboarding: No onboarding sequence assigned, skipping");
return;
}
StartSequence(OnboardingSequence, _state.OnboardingStepReached);
}
/// <summary>
/// Starts a specific tutorial sequence.
/// </summary>
public void StartSequence(TutorialSequence sequence, int startStep = 0) {
if (sequence == null) return;
if (!TutorialsEnabled || _state.AllTutorialsDisabled) {
Log($"StartSequence: Tutorials disabled, skipping '{sequence.SequenceId}'");
return;
}
// Check prerequisites
if (!sequence.ArePrerequisitesMet(_state)) {
Log($"StartSequence: Prerequisites not met for '{sequence.SequenceId}'");
return;
}
// Stop any active sequence
if (_activeSequence != null) { StopCurrentSequence(skipped: true); }
_activeSequence = sequence;
_currentStepIndex = Mathf.Clamp(startStep, 0, sequence.StepCount - 1);
Log($"Starting tutorial sequence '{sequence.SequenceId}' at step {_currentStepIndex}");
sequence.OnSequenceStart?.Invoke();
ShowCurrentStep();
}
/// <summary>
/// Triggers a contextual tutorial by ID if not already seen.
/// </summary>
public void TriggerContextualTutorial(string tutorialId) {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
if (_state.HasCompletedTutorial(tutorialId)) return;
// Look up the tutorial sequence by ID
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
if (sequence != null) { StartSequence(sequence); }
}
/// <summary>
/// Advances to the next step in the current sequence.
/// </summary>
public void AdvanceStep() {
if (_activeSequence == null) return;
var currentStep = CurrentStep;
string nextStepId = currentStep?.NextStepOverride;
// Determine next step index
int nextIndex;
if (!string.IsNullOrEmpty(nextStepId)) {
nextIndex = _activeSequence.IndexOfStep(nextStepId);
if (nextIndex < 0) nextIndex = _currentStepIndex + 1;
} else {
nextIndex = _currentStepIndex + 1;
}
// Check if sequence is complete
if (nextIndex >= _activeSequence.StepCount) {
CompleteCurrentSequence();
return;
}
_currentStepIndex = nextIndex;
// Track onboarding progress
if (_activeSequence.IsOnboarding) { _state.SetOnboardingStep(_currentStepIndex); }
Log($"Advanced to step {_currentStepIndex}: '{CurrentStep?.StepId}'");
ShowCurrentStep();
}
/// <summary>
/// Skips the current tutorial step.
/// </summary>
public void SkipCurrentStep() {
if (_activeSequence == null) return;
var step = CurrentStep;
if (step != null && !step.AllowSkip) {
Log("SkipCurrentStep: Current step does not allow skipping");
return;
}
AdvanceStep();
}
/// <summary>
/// Skips all remaining steps in the current sequence.
/// </summary>
public void SkipCurrentSequence() {
if (_activeSequence == null) return;
StopCurrentSequence(skipped: true);
}
/// <summary>
/// Skips the entire onboarding and marks it complete.
/// </summary>
public void SkipAllOnboarding() {
_state.CompleteOnboarding();
if (_activeSequence != null && _activeSequence.IsOnboarding) {
StopCurrentSequence(skipped: true);
}
}
/// <summary>
/// Disables all tutorials permanently (until re-enabled).
/// </summary>
public void DisableAllTutorials() {
_state.DisableAllTutorials();
if (_activeSequence != null) { StopCurrentSequence(skipped: true); }
}
/// <summary>
/// Checks if a specific tutorial has been seen.
/// </summary>
public bool HasSeenTutorial(string tutorialId) {
return _state.HasCompletedTutorial(tutorialId);
}
/// <summary>
/// Checks if a hint should be shown (not dismissed).
/// </summary>
public bool ShouldShowHint(string hintId) {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return false;
return !_state.HasDismissedHint(hintId);
}
/// <summary>
/// Called when a game event occurs that might trigger or complete a tutorial step.
/// </summary>
public void OnGameEvent(string eventId, object context = null) {
Log($"Game event: '{eventId}'");
// Check if this completes the current step
var currentStep = CurrentStep;
if (currentStep != null &&
currentStep.CompletionType == TutorialCompletionType.GameEvent &&
currentStep.CompletionEventId == eventId) {
AdvanceStep();
return;
}
// Check triggers for contextual tutorials
_triggerRegistry?.OnGameEvent(eventId, context);
}
/// <summary>
/// Resets all tutorial progress (for testing).
/// </summary>
public void ResetAllProgress() {
StopCurrentSequence(skipped: true);
_state.Reset();
Log("All tutorial progress reset");
}
private void ShowCurrentStep() {
var step = CurrentStep;
if (step == null) {
CompleteCurrentSequence();
return;
}
// Stop any previous step coroutine
if (_activeStepCoroutine != null) {
StopCoroutine(_activeStepCoroutine);
_activeStepCoroutine = null;
}
// Hide previous UI
UIManager?.HideAll();
// Handle different display modes
switch (step.DisplayMode) {
case TutorialDisplayMode.Modal:
UIManager?.ShowModal(
step,
_currentStepIndex,
_activeSequence.StepCount,
OnStepComplete,
OnStepSkip,
_activeSequence.IsOnboarding);
break;
case TutorialDisplayMode.Overlay:
UIManager?.ShowOverlay(step, OnStepComplete);
break;
case TutorialDisplayMode.Tooltip:
UIManager?.ShowTooltip(step, OnStepComplete);
break;
case TutorialDisplayMode.Hint:
UIManager?.ShowHint(step.StepId, step.TargetGameObjectPath, step.Description);
// Hints don't block - advance immediately
AdvanceStep();
break;
case TutorialDisplayMode.None:
// Invisible step - just wait for completion event
break;
}
// Handle timer-based completion
if (step.CompletionType == TutorialCompletionType.Timer && step.AutoAdvanceDelay > 0) {
_activeStepCoroutine = StartCoroutine(AutoAdvanceAfterDelay(step.AutoAdvanceDelay));
}
// Play audio if present
if (step.EffectClip != null) {
// TODO: Play via audio manager
}
}
private IEnumerator AutoAdvanceAfterDelay(float delay) {
yield return new WaitForSeconds(delay);
AdvanceStep();
}
private void OnStepComplete() { AdvanceStep(); }
private void OnStepSkip() { SkipCurrentStep(); }
private void CompleteCurrentSequence() {
if (_activeSequence == null) return;
var sequence = _activeSequence;
Log($"Completed tutorial sequence '{sequence.SequenceId}'");
// Mark as completed
_state.MarkTutorialCompleted(sequence.SequenceId);
if (sequence.IsOnboarding) { _state.CompleteOnboarding(); }
sequence.OnSequenceComplete?.Invoke();
_activeSequence = null;
_currentStepIndex = 0;
UIManager?.HideAll();
}
private void StopCurrentSequence(bool skipped) {
if (_activeSequence == null) return;
var sequence = _activeSequence;
Log($"Stopped tutorial sequence '{sequence.SequenceId}' (skipped: {skipped})");
if (skipped) { sequence.OnSequenceSkipped?.Invoke(); }
if (_activeStepCoroutine != null) {
StopCoroutine(_activeStepCoroutine);
_activeStepCoroutine = null;
}
_activeSequence = null;
_currentStepIndex = 0;
UIManager?.HideAll();
}
private void Log(string message) {
if (DebugLogging) { Debug.Log($"[Tutorial] {message}"); }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c529affa73b04d129ec5826ed0ac75e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Persistence layer for tutorial completion state.
/// Uses PlayerPrefs with JSON serialization for complex state.
/// </summary>
[Serializable]
public class TutorialState {
private const string PrefsKey = "Eagle0_TutorialState";
private const int CurrentVersion = 1;
// Persisted data
public int Version = CurrentVersion;
public bool OnboardingCompleted;
public int OnboardingStepReached;
public List<string> CompletedTutorials = new List<string>();
public List<string> DismissedHints = new List<string>();
public bool AllTutorialsDisabled;
// Runtime sets for faster lookup (populated from lists on load)
[NonSerialized]
private HashSet<string> _completedTutorialsSet;
[NonSerialized]
private HashSet<string> _dismissedHintsSet;
/// <summary>
/// Loads tutorial state from PlayerPrefs, or creates new state if none exists.
/// </summary>
public static TutorialState Load() {
if (!PlayerPrefs.HasKey(PrefsKey)) {
Debug.Log("TutorialState: No saved state found, creating new");
return new TutorialState();
}
try {
string json = PlayerPrefs.GetString(PrefsKey);
var state = JsonUtility.FromJson<TutorialState>(json);
// Handle version migration if needed
if (state.Version < CurrentVersion) { state = MigrateState(state); }
state.BuildRuntimeSets();
Debug.Log(
$"TutorialState: Loaded state - Onboarding: {state.OnboardingCompleted}, Completed: {state.CompletedTutorials.Count}");
return state;
} catch (Exception e) {
Debug.LogWarning(
$"TutorialState: Failed to load state, creating new. Error: {e.Message}");
return new TutorialState();
}
}
/// <summary>
/// Saves current state to PlayerPrefs.
/// </summary>
public void Save() {
try {
string json = JsonUtility.ToJson(this);
PlayerPrefs.SetString(PrefsKey, json);
PlayerPrefs.Save();
Debug.Log("TutorialState: State saved");
} catch (Exception e) {
Debug.LogError($"TutorialState: Failed to save state. Error: {e.Message}");
}
}
/// <summary>
/// Resets all tutorial state (for testing or user request).
/// </summary>
public void Reset() {
OnboardingCompleted = false;
OnboardingStepReached = 0;
CompletedTutorials.Clear();
DismissedHints.Clear();
AllTutorialsDisabled = false;
BuildRuntimeSets();
Save();
Debug.Log("TutorialState: State reset");
}
/// <summary>
/// Marks a specific tutorial as completed.
/// </summary>
public void MarkTutorialCompleted(string tutorialId) {
if (string.IsNullOrEmpty(tutorialId)) return;
EnsureRuntimeSets();
if (_completedTutorialsSet.Add(tutorialId)) {
CompletedTutorials.Add(tutorialId);
Save();
Debug.Log($"TutorialState: Marked tutorial '{tutorialId}' as completed");
}
}
/// <summary>
/// Marks a hint as dismissed (won't show again).
/// </summary>
public void MarkHintDismissed(string hintId) {
if (string.IsNullOrEmpty(hintId)) return;
EnsureRuntimeSets();
if (_dismissedHintsSet.Add(hintId)) {
DismissedHints.Add(hintId);
Save();
Debug.Log($"TutorialState: Marked hint '{hintId}' as dismissed");
}
}
/// <summary>
/// Checks if a tutorial has been completed.
/// </summary>
public bool HasCompletedTutorial(string tutorialId) {
if (string.IsNullOrEmpty(tutorialId)) return false;
EnsureRuntimeSets();
return _completedTutorialsSet.Contains(tutorialId);
}
/// <summary>
/// Checks if a hint has been dismissed.
/// </summary>
public bool HasDismissedHint(string hintId) {
if (string.IsNullOrEmpty(hintId)) return false;
EnsureRuntimeSets();
return _dismissedHintsSet.Contains(hintId);
}
/// <summary>
/// Marks the onboarding as completed.
/// </summary>
public void CompleteOnboarding() {
OnboardingCompleted = true;
Save();
Debug.Log("TutorialState: Onboarding completed");
}
/// <summary>
/// Updates the furthest onboarding step reached.
/// </summary>
public void SetOnboardingStep(int step) {
if (step > OnboardingStepReached) {
OnboardingStepReached = step;
Save();
}
}
/// <summary>
/// Disables all tutorials (user preference).
/// </summary>
public void DisableAllTutorials() {
AllTutorialsDisabled = true;
Save();
Debug.Log("TutorialState: All tutorials disabled");
}
/// <summary>
/// Re-enables tutorials.
/// </summary>
public void EnableTutorials() {
AllTutorialsDisabled = false;
Save();
Debug.Log("TutorialState: Tutorials re-enabled");
}
private void BuildRuntimeSets() {
_completedTutorialsSet = new HashSet<string>(CompletedTutorials ?? new List<string>());
_dismissedHintsSet = new HashSet<string>(DismissedHints ?? new List<string>());
}
private void EnsureRuntimeSets() {
if (_completedTutorialsSet == null || _dismissedHintsSet == null) {
BuildRuntimeSets();
}
}
private static TutorialState MigrateState(TutorialState oldState) {
// Future: Handle migrations between versions
Debug.Log(
$"TutorialState: Migrating from version {oldState.Version} to {CurrentVersion}");
oldState.Version = CurrentVersion;
return oldState;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2424323130a24ac381f535ed932c34d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,100 @@
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Test component that sets up a simple tutorial for end-to-end testing.
/// Attach to TutorialManager GameObject to register test tutorials on startup.
/// Can be removed once actual tutorial content is created in the Editor.
/// </summary>
public class TutorialTestSetup : MonoBehaviour {
[Header("Test Configuration")]
[Tooltip("Enable test tutorials")]
public bool EnableTestTutorials = true;
[Tooltip("Reset tutorial progress on startup (for testing)")]
public bool ResetProgressOnStart = false;
private void Start() {
if (!EnableTestTutorials) return;
var manager = TutorialManager.Instance;
if (manager == null) {
Debug.LogWarning("TutorialTestSetup: TutorialManager not found");
return;
}
if (ResetProgressOnStart) { manager.ResetAllProgress(); }
RegisterTestTutorials(manager);
Debug.Log("TutorialTestSetup: Test tutorials registered");
}
private void RegisterTestTutorials(TutorialManager manager) {
// Create a simple "first province selected" tutorial
var provinceSelectedTutorial = CreateProvinceSelectedTutorial();
manager.TriggerRegistry.RegisterTutorial(provinceSelectedTutorial, "province_selected");
// Create a simple "first battle" tutorial
var firstBattleTutorial = CreateFirstBattleTutorial();
manager.TriggerRegistry.RegisterTutorial(firstBattleTutorial, "battle_entered");
// Create a simple "first command" tutorial
var firstCommandTutorial = CreateFirstCommandTutorial();
manager.TriggerRegistry.RegisterTutorial(firstCommandTutorial, "command_issued");
}
private TutorialSequence CreateProvinceSelectedTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_province_selected";
sequence.DisplayName = "Province Selection";
sequence.Steps.Add(new TutorialStep {
StepId = "province_selected_intro",
Title = "Province Selected!",
Description =
"You've selected a province. Here you can see information about the province and issue commands to your forces.\n\n(This is a test tutorial to verify the tutorial system is working.)",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
return sequence;
}
private TutorialSequence CreateFirstBattleTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_first_battle";
sequence.DisplayName = "First Battle";
sequence.Steps.Add(new TutorialStep {
StepId = "battle_intro",
Title = "Battle Begins!",
Description =
"You've entered tactical combat. Command your units on the hex grid to defeat the enemy.\n\n(This is a test tutorial to verify the tutorial system is working.)",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
return sequence;
}
private TutorialSequence CreateFirstCommandTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_first_command";
sequence.DisplayName = "First Command";
sequence.Steps.Add(new TutorialStep {
StepId = "command_issued_intro",
Title = "Command Issued!",
Description =
"You've issued your first command. Commands are processed at the end of each turn.\n\n(This is a test tutorial to verify the tutorial system is working.)",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
return sequence;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 370b3269832442e196d1b432bdae2f1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a53ad5e16bc649fe9344b1a1c700dbd8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,146 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Small pulsing dot indicator for subtle hints.
/// Attaches to UI elements to draw attention without interrupting gameplay.
/// Shows tooltip on hover, triggers full tutorial on click.
/// </summary>
public class TutorialHintIndicator : MonoBehaviour,
IPointerEnterHandler,
IPointerExitHandler,
IPointerClickHandler {
[Header("Appearance")]
[Tooltip("The dot/indicator image")]
public Image DotImage;
[Tooltip("Normal color of the dot")]
public Color NormalColor = new Color(1f, 0.8f, 0f, 0.9f);
[Tooltip("Pulse highlight color")]
public Color PulseColor = new Color(1f, 1f, 0.5f, 1f);
[Tooltip("Size of the indicator")]
public float Size = 20f;
[Header("Animation")]
[Tooltip("Pulse animation period in seconds")]
public float PulsePeriod = 1.0f;
[Tooltip("Pulse scale multiplier")]
public float PulseScale = 1.3f;
// Runtime state
[HideInInspector]
public string HintId;
private string _tooltipText;
private Transform _targetTransform;
private RectTransform _rectTransform;
private float _pulseTimer;
private bool _isHovered;
private void Awake() {
_rectTransform = GetComponent<RectTransform>();
if (_rectTransform == null) {
_rectTransform = gameObject.AddComponent<RectTransform>();
}
// Set up image if not assigned
if (DotImage == null) {
DotImage = GetComponent<Image>();
if (DotImage == null) { DotImage = gameObject.AddComponent<Image>(); }
}
// Default to circle sprite or solid color
DotImage.color = NormalColor;
}
/// <summary>
/// Initialize the hint indicator.
/// </summary>
public void Initialize(string hintId, Transform target, string tooltipText) {
HintId = hintId;
_targetTransform = target;
_tooltipText = tooltipText;
// Set size
_rectTransform.sizeDelta = new Vector2(Size, Size);
// Position near target
UpdatePosition();
}
private void Update() {
// Pulse animation
_pulseTimer += Time.deltaTime;
float t = (Mathf.Sin(_pulseTimer * Mathf.PI * 2f / PulsePeriod) + 1f) / 2f;
if (!_isHovered) {
// Pulse color
DotImage.color = Color.Lerp(NormalColor, PulseColor, t);
// Pulse scale
float scale = 1f + (PulseScale - 1f) * t;
transform.localScale = Vector3.one * scale;
}
// Follow target
if (_targetTransform != null) { UpdatePosition(); }
}
private void UpdatePosition() {
if (_targetTransform == null) return;
RectTransform targetRect = _targetTransform as RectTransform;
if (targetRect == null) { targetRect = _targetTransform.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Position at top-right corner of target
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
Vector3 topRight = corners[2]; // Top-right corner
transform.position = topRight + new Vector3(-Size / 2, -Size / 2, 0);
}
}
public void OnPointerEnter(PointerEventData eventData) {
_isHovered = true;
// Highlight
DotImage.color = PulseColor;
transform.localScale = Vector3.one * PulseScale;
// Show tooltip
if (!string.IsNullOrEmpty(_tooltipText)) {
// Find HoveringTooltip in scene and show text
var tooltip = FindObjectOfType<HoveringTooltip>();
if (tooltip != null) { tooltip.Text = _tooltipText; }
}
}
public void OnPointerExit(PointerEventData eventData) {
_isHovered = false;
// Reset appearance (will resume pulsing in Update)
// Hide tooltip
var tooltip = FindObjectOfType<HoveringTooltip>();
if (tooltip != null) { tooltip.Text = ""; }
}
public void OnPointerClick(PointerEventData eventData) {
// Trigger the full tutorial for this hint
TutorialManager.Instance?.TriggerContextualTutorial(HintId);
// Mark hint as dismissed
TutorialManager.Instance?.State.MarkHintDismissed(HintId);
// Destroy this indicator
Destroy(gameObject);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f3f3016726d6473da721d6e468baab8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,155 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Modal dialog panel for displaying tutorial content.
/// Shows title, description, icon, and action buttons.
/// </summary>
public class TutorialModalPanel : MonoBehaviour {
[Header("Content")]
[Tooltip("Title text")]
public TextMeshProUGUI TitleText;
[Tooltip("Description text")]
public TextMeshProUGUI DescriptionText;
[Tooltip("Icon image")]
public Image IconImage;
[Header("Buttons")]
[Tooltip("Continue/Got it button")]
public Button ContinueButton;
[Tooltip("Text on continue button")]
public TextMeshProUGUI ContinueButtonText;
[Tooltip("Skip this step button")]
public Button SkipButton;
[Tooltip("Skip all tutorials button (onboarding only)")]
public Button SkipAllButton;
[Header("Progress")]
[Tooltip("Progress text (e.g., 'Step 3 of 7')")]
public TextMeshProUGUI ProgressText;
[Tooltip("Progress slider/bar")]
public Slider ProgressSlider;
[Header("Background")]
[Tooltip("Modal blocker/dimmer")]
public GameObject ModalBlocker;
[Tooltip("Panel container")]
public GameObject PanelContainer;
[Header("Animation")]
[Tooltip("Animator for panel animations")]
public Animator PanelAnimator;
// 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
Hide();
}
/// <summary>
/// Shows the modal with tutorial step content.
/// </summary>
public void Show(
TutorialStep step,
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_onContinue = onContinue;
_onSkip = onSkip;
// Set content
if (TitleText != null) { TitleText.text = step.Title ?? ""; }
if (DescriptionText != null) { DescriptionText.text = step.Description ?? ""; }
if (IconImage != null) {
if (step.Icon != null) {
IconImage.sprite = step.Icon;
IconImage.gameObject.SetActive(true);
} else {
IconImage.gameObject.SetActive(false);
}
}
// Set button text
if (ContinueButtonText != null) {
ContinueButtonText.text = currentIndex >= totalSteps - 1 ? "Got it!" : "Continue";
}
// Show/hide skip buttons
if (SkipButton != null) { SkipButton.gameObject.SetActive(step.AllowSkip); }
if (SkipAllButton != null) {
SkipAllButton.gameObject.SetActive(isOnboarding && step.AllowSkip);
}
// Set progress
if (ProgressText != null) {
ProgressText.text = $"Step {currentIndex + 1} of {totalSteps}";
ProgressText.gameObject.SetActive(totalSteps > 1);
}
if (ProgressSlider != null) {
ProgressSlider.value = (float)(currentIndex + 1) / totalSteps;
ProgressSlider.gameObject.SetActive(totalSteps > 1);
}
// Show panel
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
if (PanelContainer != null) { PanelContainer.SetActive(true); }
gameObject.SetActive(true);
// Trigger animation
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Hides the modal panel.
/// </summary>
public void Hide() {
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Hide"); }
if (ModalBlocker != null) { ModalBlocker.SetActive(false); }
if (PanelContainer != null) { PanelContainer.SetActive(false); }
gameObject.SetActive(false);
_onContinue = null;
_onSkip = null;
}
private void OnContinueClicked() {
var callback = _onContinue;
Hide();
callback?.Invoke();
}
private void OnSkipClicked() {
var callback = _onSkip;
Hide();
callback?.Invoke();
}
private void OnSkipAllClicked() {
Hide();
TutorialManager.Instance?.SkipAllOnboarding();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 461d5eac2fdd4d509a15185a42677886
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,278 @@
using System;
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Semi-transparent overlay that highlights specific UI elements.
/// Dims the rest of the screen and shows explanatory text.
/// </summary>
public class TutorialOverlayController : MonoBehaviour {
[Header("Background")]
[Tooltip("Semi-transparent background that dims the screen")]
public Image BackgroundDimmer;
[Tooltip("Background dimmer alpha")]
public float DimmerAlpha = 0.7f;
[Header("Highlight")]
[Tooltip("The highlight frame/border around target element")]
public RectTransform HighlightFrame;
[Tooltip("Padding around the highlighted element")]
public float HighlightPadding = 10f;
[Tooltip("Highlight border color")]
public Color HighlightColor = Color.yellow;
[Header("Tooltip")]
[Tooltip("Container for tooltip content")]
public RectTransform TooltipContainer;
[Tooltip("Tooltip title text")]
public TextMeshProUGUI TooltipTitle;
[Tooltip("Tooltip description text")]
public TextMeshProUGUI TooltipDescription;
[Tooltip("Arrow/pointer image")]
public Image ArrowPointer;
[Tooltip("Continue button in tooltip")]
public Button ContinueButton;
[Header("Animation")]
[Tooltip("Duration of fade in/out")]
public float FadeDuration = 0.3f;
[Tooltip("Pulse animation period")]
public float PulsePeriod = 1.5f;
[Tooltip("Pulse scale amount")]
public float PulseAmount = 0.05f;
// State
private Transform _currentTarget;
private Action _onComplete;
private Coroutine _pulseCoroutine;
private CanvasGroup _canvasGroup;
private void Awake() {
_canvasGroup = GetComponent<CanvasGroup>();
if (_canvasGroup == null) { _canvasGroup = gameObject.AddComponent<CanvasGroup>(); }
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
// Start hidden
gameObject.SetActive(false);
}
/// <summary>
/// Shows the overlay highlighting a target element.
/// </summary>
public void ShowOverlay(
Transform target,
string title,
string description,
Vector2 offset,
bool pulsing,
Action onComplete) {
_currentTarget = target;
_onComplete = onComplete;
// Set content
if (TooltipTitle != null) {
TooltipTitle.text = title ?? "";
TooltipTitle.gameObject.SetActive(!string.IsNullOrEmpty(title));
}
if (TooltipDescription != null) { TooltipDescription.text = description ?? ""; }
// Position elements
if (target != null) {
PositionHighlight(target);
PositionTooltip(target, offset);
} else {
// No target - center the tooltip
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(false); }
if (ArrowPointer != null) { ArrowPointer.gameObject.SetActive(false); }
CenterTooltip();
}
// Show
gameObject.SetActive(true);
StartCoroutine(FadeIn());
// Start pulsing if requested
if (pulsing && target != null) { _pulseCoroutine = StartCoroutine(PulseHighlight()); }
}
/// <summary>
/// Shows only the highlight without tooltip (for emphasis).
/// </summary>
public void HighlightOnly(Transform target) {
_currentTarget = target;
if (target != null) {
PositionHighlight(target);
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
}
// Hide tooltip elements
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
gameObject.SetActive(true);
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
/// <summary>
/// Clears highlight without hiding overlay.
/// </summary>
public void ClearHighlight() {
if (_pulseCoroutine != null) {
StopCoroutine(_pulseCoroutine);
_pulseCoroutine = null;
}
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(false); }
_currentTarget = null;
}
/// <summary>
/// Hides the overlay completely.
/// </summary>
public void HideOverlay() {
if (_pulseCoroutine != null) {
StopCoroutine(_pulseCoroutine);
_pulseCoroutine = null;
}
StartCoroutine(FadeOut());
}
private void PositionHighlight(Transform target) {
if (HighlightFrame == null || target == null) return;
RectTransform targetRect = target as RectTransform;
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Get target bounds in screen space
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
// Convert to canvas space
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]); }
}
// 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);
// 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.position = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
}
private void PositionTooltip(Transform target, Vector2 offset) {
if (TooltipContainer == null) return;
TooltipContainer.gameObject.SetActive(true);
if (target != null) {
RectTransform targetRect = target as RectTransform;
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Position tooltip near target with offset
Vector3 targetPos = targetRect.position;
TooltipContainer.position = targetPos + new Vector3(offset.x, offset.y, 0);
// Position arrow to point at target
if (ArrowPointer != null) {
ArrowPointer.gameObject.SetActive(true);
// Arrow rotation based on relative position could be added here
}
}
}
}
private void CenterTooltip() {
if (TooltipContainer == null) return;
TooltipContainer.gameObject.SetActive(true);
TooltipContainer.anchoredPosition = Vector2.zero;
if (ArrowPointer != null) { ArrowPointer.gameObject.SetActive(false); }
}
private IEnumerator FadeIn() {
float elapsed = 0f;
while (elapsed < FadeDuration) {
elapsed += Time.deltaTime;
float t = elapsed / FadeDuration;
if (_canvasGroup != null) { _canvasGroup.alpha = t; }
yield return null;
}
if (_canvasGroup != null) { _canvasGroup.alpha = 1f; }
}
private IEnumerator FadeOut() {
float elapsed = 0f;
while (elapsed < FadeDuration) {
elapsed += Time.deltaTime;
float t = 1f - (elapsed / FadeDuration);
if (_canvasGroup != null) { _canvasGroup.alpha = t; }
yield return null;
}
gameObject.SetActive(false);
_currentTarget = null;
_onComplete = null;
}
private IEnumerator PulseHighlight() {
if (HighlightFrame == null) yield break;
Vector3 baseScale = HighlightFrame.localScale;
float elapsed = 0f;
while (true) {
elapsed += Time.deltaTime;
float t = (Mathf.Sin(elapsed * Mathf.PI * 2f / PulsePeriod) + 1f) / 2f;
float scale = 1f + t * PulseAmount;
HighlightFrame.localScale = baseScale * scale;
yield return null;
}
}
private void OnContinueClicked() {
var callback = _onComplete;
HideOverlay();
callback?.Invoke();
}
private void Update() {
// Follow target if it moves
if (_currentTarget != null && HighlightFrame != null &&
HighlightFrame.gameObject.activeSelf) {
PositionHighlight(_currentTarget);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3cf75368a9b643a18f00ff43e51af7c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Manages all tutorial UI elements and their presentation.
/// Coordinates between modal dialogs, overlays, and hint indicators.
/// </summary>
public class TutorialUIManager : MonoBehaviour {
[Header("UI Components")]
[Tooltip("Modal dialog panel for important tutorials")]
public TutorialModalPanel ModalPanel;
[Tooltip("Overlay controller for highlighting UI elements")]
public TutorialOverlayController OverlayController;
[Tooltip("Prefab for hint indicator dots")]
public TutorialHintIndicator HintIndicatorPrefab;
[Header("Canvas")]
[Tooltip("Canvas for tutorial UI (should be above game UI)")]
public Canvas TutorialCanvas;
[Header("Fallback UI")]
[Tooltip("Use IMGUI fallback when no ModalPanel is assigned")]
public bool UseFallbackUI = true;
[Tooltip("Font for IMGUI fallback (assign Stoke-Regular)")]
public Font FallbackFont;
// Active hints
private Dictionary<string, TutorialHintIndicator> _activeHints =
new Dictionary<string, TutorialHintIndicator>();
// Fallback modal state
private bool _fallbackModalVisible;
private TutorialStep _fallbackStep;
private Action _fallbackOnContinue;
private Action _fallbackOnSkip;
private int _fallbackCurrentIndex;
private int _fallbackTotalSteps;
private bool _fallbackIsOnboarding;
private void Awake() {
// Ensure canvas is set up correctly
if (TutorialCanvas != null) {
TutorialCanvas.sortingOrder = 1000; // Above most game UI
}
}
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;
// 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;
GUIStyle windowStyle = new GUIStyle(GUI.skin.window);
windowStyle.fontSize = 48;
if (FallbackFont != null) windowStyle.font = FallbackFont;
GUI.Window(
12345,
new Rect(windowX, windowY, windowWidth, windowHeight),
DrawFallbackModal,
_fallbackStep.Title ?? "Tutorial",
windowStyle);
}
private void DrawFallbackModal(int windowId) {
GUILayout.Space(60);
// Description
GUIStyle descStyle = new GUIStyle(GUI.skin.label);
descStyle.wordWrap = true;
descStyle.fontSize = 40;
if (FallbackFont != null) descStyle.font = FallbackFont;
GUILayout.Label(
_fallbackStep.Description ?? "",
descStyle,
GUILayout.ExpandHeight(true));
GUILayout.FlexibleSpace();
// Progress
if (_fallbackTotalSteps > 1) {
GUIStyle progressStyle = new GUIStyle(GUI.skin.label);
progressStyle.fontSize = 32;
if (FallbackFont != null) progressStyle.font = FallbackFont;
GUILayout.Label(
$"Step {_fallbackCurrentIndex + 1} of {_fallbackTotalSteps}",
progressStyle,
GUILayout.ExpandWidth(true));
}
GUILayout.Space(30);
// Button style
GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
buttonStyle.fontSize = 36;
if (FallbackFont != null) buttonStyle.font = FallbackFont;
// Buttons
GUILayout.BeginHorizontal();
if (_fallbackStep.AllowSkip) {
if (GUILayout.Button(
"Skip",
buttonStyle,
GUILayout.Width(200),
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();
}
}
}
GUILayout.FlexibleSpace();
string buttonText =
_fallbackCurrentIndex >= _fallbackTotalSteps - 1 ? "Got it!" : "Continue";
if (GUILayout.Button(
buttonText,
buttonStyle,
GUILayout.Width(240),
GUILayout.Height(80))) {
var callback = _fallbackOnContinue;
HideFallbackModal();
callback?.Invoke();
}
GUILayout.EndHorizontal();
GUILayout.Space(30);
}
private void ShowFallbackModal(
TutorialStep step,
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_fallbackStep = step;
_fallbackCurrentIndex = currentIndex;
_fallbackTotalSteps = totalSteps;
_fallbackOnContinue = onContinue;
_fallbackOnSkip = onSkip;
_fallbackIsOnboarding = isOnboarding;
_fallbackModalVisible = true;
}
private void HideFallbackModal() {
_fallbackModalVisible = false;
_fallbackStep = null;
_fallbackOnContinue = null;
_fallbackOnSkip = null;
}
/// <summary>
/// Shows a modal dialog for a tutorial step.
/// </summary>
public void ShowModal(
TutorialStep step,
int currentIndex,
int totalSteps,
Action onComplete,
Action onSkip,
bool isOnboarding) {
if (ModalPanel != null) {
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
} else if (UseFallbackUI) {
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
} else {
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned and fallback disabled");
onComplete?.Invoke();
}
}
/// <summary>
/// Shows an overlay highlighting a specific UI element.
/// </summary>
public void ShowOverlay(TutorialStep step, Action onComplete) {
if (OverlayController == null) {
Debug.LogWarning("TutorialUIManager: No OverlayController assigned");
onComplete?.Invoke();
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}'");
}
}
OverlayController.ShowOverlay(
target,
step.Title,
step.Description,
step.HighlightOffset,
step.HighlightPulsing,
onComplete);
}
/// <summary>
/// Shows a tooltip near a target element.
/// </summary>
public void ShowTooltip(TutorialStep step, Action onComplete) {
// For now, use overlay with smaller presentation
// Could be enhanced to use a dedicated tooltip component
ShowOverlay(step, onComplete);
}
/// <summary>
/// Shows a hint indicator on a UI element.
/// </summary>
public void ShowHint(string hintId, string targetPath, string tooltipText) {
if (HintIndicatorPrefab == null) {
Debug.LogWarning("TutorialUIManager: No HintIndicatorPrefab assigned");
return;
}
// 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; }
}
if (target == null) {
Debug.LogWarning($"TutorialUIManager: Could not find hint target '{targetPath}'");
return;
}
// Create hint indicator
var hint = Instantiate(HintIndicatorPrefab, TutorialCanvas.transform);
hint.Initialize(hintId, target, tooltipText);
_activeHints[hintId] = hint;
}
/// <summary>
/// Hides a specific hint.
/// </summary>
public void HideHint(string hintId) {
if (_activeHints.TryGetValue(hintId, out var hint)) {
if (hint != null) { Destroy(hint.gameObject); }
_activeHints.Remove(hintId);
}
}
/// <summary>
/// Hides all active hints.
/// </summary>
public void HideAllHints() {
foreach (var hint in _activeHints.Values) {
if (hint != null) { Destroy(hint.gameObject); }
}
_activeHints.Clear();
}
/// <summary>
/// Hides all tutorial UI.
/// </summary>
public void HideAll() {
ModalPanel?.Hide();
OverlayController?.HideOverlay();
HideFallbackModal();
// Note: Don't hide hints automatically - they persist until dismissed
}
/// <summary>
/// Highlights a specific UI element (without showing tutorial content).
/// </summary>
public void HighlightElement(string gameObjectPath) {
if (OverlayController == null) return;
Transform target = null;
if (!string.IsNullOrEmpty(gameObjectPath)) {
var targetObj = GameObject.Find(gameObjectPath);
if (targetObj != null) { target = targetObj.transform; }
}
if (target != null) { OverlayController.HighlightOnly(target); }
}
/// <summary>
/// Clears any active highlight.
/// </summary>
public void ClearHighlight() { OverlayController?.ClearHighlight(); }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 52abae862492497cb7a3f8c72f837b37
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,53 +1,5 @@
using System;
using Microsoft.Win32;
namespace EagleInstaller {
public class CredentialManager {
private const string RegistryKeyPath = @"SOFTWARE\Eagle0\Launcher";
private const string ServerUrlValueName = "ServerUrl";
private const string DefaultServerUrl = "https://assets.eagle0.net";
/// <summary>
/// Loads saved server URL. Returns default if none found.
/// </summary>
public static string LoadServerUrl() {
try {
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false)) {
if (key != null) {
var url = key.GetValue(ServerUrlValueName) as string;
if (!string.IsNullOrEmpty(url)) { return url; }
}
}
} catch (Exception) {
// If anything fails, return default
}
return DefaultServerUrl;
}
/// <summary>
/// Saves server URL to registry.
/// </summary>
public static void SaveServerUrl(string serverUrl) {
try {
using (var key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath)) {
key.SetValue(ServerUrlValueName, serverUrl ?? DefaultServerUrl);
}
} catch (Exception) {
// Ignore errors when saving
}
}
/// <summary>
/// Clears saved settings.
/// </summary>
public static void ClearSettings() {
try {
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true)) {
if (key != null) { key.DeleteValue(ServerUrlValueName, false); }
}
} catch (Exception) {
// Ignore errors when clearing
}
}
public const string ServerUrl = "https://assets.eagle0.net";
}
}
@@ -9,12 +9,9 @@ namespace EagleInstaller {
private Label _statusLabel;
private Button _exitButton;
public string ServerUrl { get; private set; }
public string ServerUrl => CredentialManager.ServerUrl;
public MainForm(string serverUrl = null) {
ServerUrl = serverUrl ?? CredentialManager.LoadServerUrl();
InitializeComponent();
}
public MainForm() { InitializeComponent(); }
private void InitializeComponent() {
// Form properties
@@ -11,6 +11,8 @@ namespace EagleInstaller {
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"eagle0");
private static readonly string ExeLocation = Path.Combine(FileLocation, "eagle0.exe");
private static readonly string InvitationFilePath =
Path.Combine(FileLocation, "invitation.json");
private static void CleanupTempInstallerFiles() {
try {
@@ -21,6 +23,34 @@ namespace EagleInstaller {
}
}
private static string ExtractInvitationCode(string[] args) {
foreach (string arg in args) {
// Support --code=XXXX format
if (arg.StartsWith("--code=", StringComparison.OrdinalIgnoreCase)) {
return arg.Substring(7);
}
// Support -code=XXXX format
if (arg.StartsWith("-code=", StringComparison.OrdinalIgnoreCase)) {
return arg.Substring(6);
}
}
return null;
}
private static void SaveInvitationCode(string code) {
try {
// Ensure directory exists
Directory.CreateDirectory(FileLocation);
// Write simple JSON with the invitation code
string json = $"{{\"invitation_code\":\"{code}\"}}";
File.WriteAllText(InvitationFilePath, json);
Logger.WriteLine($"Saved invitation code to {InvitationFilePath}");
} catch (Exception ex) {
Logger.WriteError($"Failed to save invitation code: {ex.Message}");
}
}
static async Task Main(string[] args) {
// Enable visual styles for Windows Forms
Application.EnableVisualStyles();
@@ -43,6 +73,9 @@ namespace EagleInstaller {
return;
}
// Extract invitation code from args (if provided)
string invitationCode = ExtractInvitationCode(args);
// Create main form with default server URL
var mainForm = new MainForm();
Logger.Initialize(mainForm);
@@ -52,7 +85,7 @@ namespace EagleInstaller {
mainForm.CreateControl();
// Start the installer logic in the background
var installerTask = RunInstallerLogic(mainForm);
var installerTask = RunInstallerLogic(mainForm, invitationCode);
// Run the application with the main form
Application.Run(mainForm);
@@ -61,11 +94,15 @@ namespace EagleInstaller {
await installerTask;
}
private static async Task RunInstallerLogic(MainForm mainForm) {
private static async Task RunInstallerLogic(MainForm mainForm, string invitationCode) {
try {
string serverUrl = mainForm.ServerUrl;
Logger.WriteLine($"Using server: {serverUrl}");
if (!string.IsNullOrEmpty(invitationCode)) {
Logger.WriteLine("Invitation code provided");
}
EagleUpdater updater = new EagleUpdater(serverUrl);
// Stage 1: Check if installer itself needs update
@@ -93,6 +130,11 @@ namespace EagleInstaller {
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
if (success) {
// Save invitation code before launching game (if provided)
if (!string.IsNullOrEmpty(invitationCode)) {
SaveInvitationCode(invitationCode);
}
Logger.UpdateStatus("Launching Eagle0...");
Process.Start(ExeLocation);
@@ -243,7 +285,8 @@ namespace EagleInstaller {
Console.WriteLine(" EagleInstaller.exe [OPTIONS]");
Console.WriteLine();
Console.WriteLine("OPTIONS:");
Console.WriteLine(" --help, -h Show this help message");
Console.WriteLine(" --help, -h Show this help message");
Console.WriteLine(" --code=CODE Invitation code for new account registration");
Console.WriteLine();
Console.WriteLine("NORMAL OPERATION:");
Console.WriteLine(" Run without arguments to start the normal update process.");
@@ -1,3 +1,3 @@
manifest_name = eagle0_manifest.txt
remote_manifest_prefix = assets/installer/
remote_asset_prefix = assets/unity3d/win/
remote_manifest_prefix = installer/
remote_asset_prefix = unity3d/win/
@@ -65,16 +65,18 @@ var (
grpcClient eagle.EagleClient
adminClient adminpb.AdminClient
authClient authpb.AuthClient
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
gameStateTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
usersTemplate *template.Template
usersRowsTemplate *template.Template
loginTemplate *template.Template
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
gameStateTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
usersTemplate *template.Template
usersRowsTemplate *template.Template
invitationsTemplate *template.Template
invitationsRowsTemplate *template.Template
loginTemplate *template.Template
)
func main() {
@@ -127,6 +129,16 @@ func main() {
log.Fatalf("Failed to parse users rows template: %v", err)
}
invitationsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/invitations.html")
if err != nil {
log.Fatalf("Failed to parse invitations template: %v", err)
}
invitationsRowsTemplate, err = template.ParseFS(templatesFS, "templates/invitations_rows.html")
if err != nil {
log.Fatalf("Failed to parse invitations rows template: %v", err)
}
loginTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/login.html")
if err != nil {
log.Fatalf("Failed to parse login template: %v", err)
@@ -185,6 +197,8 @@ func main() {
http.HandleFunc("/settings/", requireAuth(handleSettingsRoutes))
http.HandleFunc("/users", requireAuth(handleUsersPage))
http.HandleFunc("/users/", requireAuth(handleUserRoutes))
http.HandleFunc("/invitations", requireAuth(handleInvitationsPage))
http.HandleFunc("/invitations/", requireAuth(handleInvitationRoutes))
http.HandleFunc("/login", handleLoginPage)
http.HandleFunc("/login/start", handleLoginStart)
http.HandleFunc("/login/complete", handleLoginComplete)
@@ -1652,6 +1666,8 @@ func handleUserRoutes(w http.ResponseWriter, r *http.Request) {
handleSetUserDisplayName(w, r, userID)
case "set-admin":
handleSetUserAdmin(w, r, userID)
case "delete":
handleDeleteUser(w, r, userID)
default:
http.NotFound(w, r)
}
@@ -1804,6 +1820,393 @@ func handleSetUserAdmin(w http.ResponseWriter, r *http.Request, userID string) {
fmt.Fprintf(w, `<div class="alert alert-success">Admin status %s</div>`, status)
}
func handleDeleteUser(w http.ResponseWriter, r *http.Request, userID string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.DeleteUser(ctx, &adminpb.DeleteUserRequest{
UserId: userID,
})
if err != nil {
log.Printf("Failed to delete user: %v", err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("HX-Trigger", "userUpdated")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">User deleted</div>`)
}
// Invitation management handlers
// InvitationInfo represents an invitation for display
type InvitationInfo struct {
InvitationCode string
InvitationCodeShort string
Email string
StatusName string
CreatedByUserID string
CreatedByDisplayName string
CreatedAt string
ExpiresAt string
RedeemedByUserID string
RedeemedByDisplayName string
}
// InvitationsPageData is the data for the invitations page
type InvitationsPageData struct {
Title string
Invitations []InvitationInfo
EmailFilter string
StatusFilter int
TotalCount int
BuildInfo BuildInfo
}
// InvitationsRowsData is the data for the invitations rows partial
type InvitationsRowsData struct {
Invitations []InvitationInfo
EmailFilter string
StatusFilter int
HasMore bool
NextOffset int
}
func statusToName(status adminpb.InvitationStatus) string {
switch status {
case adminpb.InvitationStatus_INVITATION_STATUS_PENDING:
return "pending"
case adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return "redeemed"
case adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return "expired"
case adminpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return "revoked"
default:
return "unknown"
}
}
func convertInvitationInfo(inv *adminpb.InvitationInfo) InvitationInfo {
createdAt := ""
if inv.CreatedAt != nil {
createdAt = inv.CreatedAt.AsTime().Format("2006-01-02 15:04")
}
expiresAt := ""
if inv.ExpiresAt != nil {
expiresAt = inv.ExpiresAt.AsTime().Format("2006-01-02 15:04")
}
shortCode := inv.InvitationCode
if len(shortCode) > 8 {
shortCode = shortCode[:8]
}
return InvitationInfo{
InvitationCode: inv.InvitationCode,
InvitationCodeShort: shortCode,
Email: inv.Email,
StatusName: statusToName(inv.Status),
CreatedByUserID: inv.CreatedByUserId,
CreatedByDisplayName: inv.CreatedByDisplayName,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
RedeemedByUserID: inv.RedeemedByUserId,
RedeemedByDisplayName: inv.RedeemedByDisplayName,
}
}
func handleInvitationsPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
emailFilter := r.URL.Query().Get("filter")
statusFilter := 0
if s := r.URL.Query().Get("status_filter"); s != "" {
if v, err := strconv.Atoi(s); err == nil {
statusFilter = v
}
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
EmailFilter: emailFilter,
StatusFilter: adminpb.InvitationStatus(statusFilter),
Offset: 0,
Limit: 50,
})
if err != nil {
log.Printf("Failed to list invitations: %v", err)
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
return
}
var invitations []InvitationInfo
for _, inv := range resp.Invitations {
invitations = append(invitations, convertInvitationInfo(inv))
}
data := InvitationsPageData{
Title: "Invitations",
Invitations: invitations,
EmailFilter: emailFilter,
StatusFilter: statusFilter,
TotalCount: int(resp.TotalCount),
BuildInfo: getBuildInfo(),
}
w.Header().Set("Content-Type", "text/html")
if err := invitationsTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleInvitationRoutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/invitations/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Redirect(w, r, "/invitations", http.StatusFound)
return
}
switch parts[0] {
case "search":
handleInvitationsSearch(w, r)
case "create":
handleCreateInvitation(w, r)
default:
// Assume it's an invitation code with an action
if len(parts) >= 2 {
code := parts[0]
action := parts[1]
switch action {
case "resend":
handleResendInvitation(w, r, code)
case "revoke":
handleRevokeInvitation(w, r, code)
case "delete":
handleDeleteInvitation(w, r, code)
default:
http.NotFound(w, r)
}
} else {
http.NotFound(w, r)
}
}
}
func handleInvitationsSearch(w http.ResponseWriter, r *http.Request) {
emailFilter := r.URL.Query().Get("filter")
statusFilter := 0
if s := r.URL.Query().Get("status_filter"); s != "" {
if v, err := strconv.Atoi(s); err == nil {
statusFilter = v
}
}
offset := 0
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil {
offset = v
}
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
EmailFilter: emailFilter,
StatusFilter: adminpb.InvitationStatus(statusFilter),
Offset: int32(offset),
Limit: 50,
})
if err != nil {
log.Printf("Failed to list invitations: %v", err)
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
return
}
var invitations []InvitationInfo
for _, inv := range resp.Invitations {
invitations = append(invitations, convertInvitationInfo(inv))
}
nextOffset := offset + len(invitations)
hasMore := nextOffset < int(resp.TotalCount)
data := InvitationsRowsData{
Invitations: invitations,
EmailFilter: emailFilter,
StatusFilter: statusFilter,
HasMore: hasMore,
NextOffset: nextOffset,
}
w.Header().Set("Content-Type", "text/html")
if err := invitationsRowsTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleCreateInvitation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Invalid form data: %v</div>`, err)
return
}
email := r.FormValue("email")
expiresInDays := int32(30)
if e := r.FormValue("expires_in_days"); e != "" {
if v, err := strconv.Atoi(e); err == nil {
expiresInDays = int32(v)
}
}
if email == "" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-error">Email address is required</div>`)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.CreateInvitation(ctx, &adminpb.CreateInvitationRequest{
Email: email,
ExpiresInDays: expiresInDays,
})
if err != nil {
log.Printf("Failed to create invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-success">Invitation created and sent to %s</div>`, email)
}
func handleResendInvitation(w http.ResponseWriter, r *http.Request, code string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.ResendInvitation(ctx, &adminpb.ResendInvitationRequest{
InvitationCode: code,
})
if err != nil {
log.Printf("Failed to resend invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Invitation email resent</div>`)
}
func handleRevokeInvitation(w http.ResponseWriter, r *http.Request, code string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.RevokeInvitation(ctx, &adminpb.RevokeInvitationRequest{
InvitationCode: code,
})
if err != nil {
log.Printf("Failed to revoke invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Invitation revoked</div>`)
}
func handleDeleteInvitation(w http.ResponseWriter, r *http.Request, code string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.DeleteInvitation(ctx, &adminpb.DeleteInvitationRequest{
InvitationCode: code,
})
if err != nil {
log.Printf("Failed to delete invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("HX-Trigger", "invitationUpdated")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Invitation deleted</div>`)
}
// Authentication handlers
const (
@@ -0,0 +1,309 @@
{{define "content"}}
<div class="page-header">
<h1>Invitations</h1>
<span>{{.TotalCount}} invitation{{if ne .TotalCount 1}}s{{end}}</span>
</div>
<div class="invitation-controls">
<div class="search-controls">
<input type="text"
name="filter"
placeholder="Search by email..."
value="{{.EmailFilter}}"
hx-get="/invitations/search"
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
hx-target="#invitations-table-body"
hx-swap="innerHTML"
hx-include="[name='status_filter']"
class="search-input">
<select name="status_filter"
hx-get="/invitations/search"
hx-trigger="change"
hx-target="#invitations-table-body"
hx-swap="innerHTML"
hx-include="[name='filter']"
class="status-filter">
<option value="0" {{if eq .StatusFilter 0}}selected{{end}}>All Status</option>
<option value="1" {{if eq .StatusFilter 1}}selected{{end}}>Pending</option>
<option value="2" {{if eq .StatusFilter 2}}selected{{end}}>Redeemed</option>
<option value="3" {{if eq .StatusFilter 3}}selected{{end}}>Expired</option>
<option value="4" {{if eq .StatusFilter 4}}selected{{end}}>Revoked</option>
</select>
</div>
<button class="btn-primary" onclick="openCreateModal()">Create Invitation</button>
</div>
<div id="feedback" class="feedback-area"></div>
{{if .Invitations}}
<table class="invitations-table">
<thead>
<tr>
<th>Email</th>
<th>Status</th>
<th>Created By</th>
<th>Created</th>
<th>Expires</th>
<th>Redeemed By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="invitations-table-body" hx-trigger="invitationUpdated from:body" hx-get="/invitations/search?filter={{.EmailFilter}}&status_filter={{.StatusFilter}}" hx-swap="innerHTML">
{{range .Invitations}}
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
<td class="invitation-email">{{.Email}}</td>
<td class="invitation-status">
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
</td>
<td class="invitation-created-by">
{{if .CreatedByDisplayName}}{{.CreatedByDisplayName}}{{else}}{{.CreatedByUserID}}{{end}}
</td>
<td class="invitation-created">{{.CreatedAt}}</td>
<td class="invitation-expires">{{.ExpiresAt}}</td>
<td class="invitation-redeemed-by">
{{if .RedeemedByDisplayName}}{{.RedeemedByDisplayName}}{{else if .RedeemedByUserID}}{{.RedeemedByUserID}}{{else}}-{{end}}
</td>
<td class="invitation-actions">
{{if eq .StatusName "pending"}}
<button class="btn-small"
hx-post="/invitations/{{.InvitationCode}}/resend"
hx-target="#feedback"
hx-swap="innerHTML">
Resend
</button>
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/revoke"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to revoke this invitation?">
Revoke
</button>
<button class="btn-small btn-secondary"
onclick="copyInvitationCode('{{.InvitationCode}}')">
Copy Code
</button>
{{else}}
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this invitation?">
Delete
</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No invitations found.</p>
</div>
{{end}}
<!-- Create Invitation Modal -->
<dialog id="create-modal">
<article>
<header>
<h3>Create Invitation</h3>
<button aria-label="Close" class="close" onclick="document.getElementById('create-modal').close()"></button>
</header>
<form id="create-form" hx-post="/invitations/create" hx-target="#create-feedback" hx-swap="innerHTML">
<label for="create-email">Email Address</label>
<input type="email" id="create-email" name="email" placeholder="user@example.com" required>
<label for="create-expires">Expires In (days)</label>
<input type="number" id="create-expires" name="expires_in_days" value="30" min="1" max="365">
<small>Default: 30 days</small>
<div id="create-feedback"></div>
</form>
<footer>
<button type="button" class="secondary" onclick="document.getElementById('create-modal').close()">Cancel</button>
<button type="submit" form="create-form">Create & Send</button>
</footer>
</article>
</dialog>
<script>
function openCreateModal() {
document.getElementById('create-email').value = '';
document.getElementById('create-expires').value = '30';
document.getElementById('create-feedback').innerHTML = '';
document.getElementById('create-modal').showModal();
}
function copyInvitationCode(code) {
navigator.clipboard.writeText(code).then(() => {
document.getElementById('feedback').innerHTML = '<div class="alert alert-success">Invitation code copied to clipboard</div>';
setTimeout(() => {
document.getElementById('feedback').innerHTML = '';
}, 3000);
}).catch(err => {
console.error('Failed to copy:', err);
document.getElementById('feedback').innerHTML = '<div class="alert alert-error">Failed to copy code</div>';
});
}
// Handle successful invitation creation
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.pathInfo.requestPath === '/invitations/create' && evt.detail.successful) {
const response = evt.detail.xhr.responseText;
if (response.includes('alert-success')) {
// Close modal and refresh table
setTimeout(() => {
document.getElementById('create-modal').close();
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
}, 1500);
}
} else if (evt.detail.pathInfo.requestPath.includes('/revoke') ||
evt.detail.pathInfo.requestPath.includes('/resend') ||
evt.detail.pathInfo.requestPath.includes('/delete')) {
if (evt.detail.successful) {
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
}
}
});
</script>
<style>
.invitation-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
}
.search-controls {
display: flex;
gap: 0.5rem;
flex: 1;
}
.search-input {
max-width: 300px;
}
.status-filter {
max-width: 150px;
}
.invitations-table {
width: 100%;
border-collapse: collapse;
}
.invitations-table th, .invitations-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.status-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.status-badge.pending {
background-color: #ffc107;
color: #000;
}
.status-badge.redeemed {
background-color: #28a745;
color: white;
}
.status-badge.expired {
background-color: #6c757d;
color: white;
}
.status-badge.revoked {
background-color: #dc3545;
color: white;
}
.invitation-row.status-redeemed,
.invitation-row.status-expired,
.invitation-row.status-revoked {
opacity: 0.7;
}
.invitation-actions {
display: flex;
gap: 0.25rem;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
color: white;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 4px;
}
.no-actions {
color: var(--pico-muted-color);
}
.feedback-area {
margin-bottom: 1rem;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
#create-modal article {
max-width: 500px;
}
#create-modal small {
display: block;
margin-top: -0.5rem;
margin-bottom: 1rem;
color: var(--pico-muted-color);
}
</style>
{{end}}
@@ -0,0 +1,61 @@
{{range .Invitations}}
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
<td class="invitation-email">{{.Email}}</td>
<td class="invitation-status">
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
</td>
<td class="invitation-created-by">
{{if .CreatedByDisplayName}}{{.CreatedByDisplayName}}{{else}}{{.CreatedByUserID}}{{end}}
</td>
<td class="invitation-created">{{.CreatedAt}}</td>
<td class="invitation-expires">{{.ExpiresAt}}</td>
<td class="invitation-redeemed-by">
{{if .RedeemedByDisplayName}}{{.RedeemedByDisplayName}}{{else if .RedeemedByUserID}}{{.RedeemedByUserID}}{{else}}-{{end}}
</td>
<td class="invitation-actions">
{{if eq .StatusName "pending"}}
<button class="btn-small"
hx-post="/invitations/{{.InvitationCode}}/resend"
hx-target="#feedback"
hx-swap="innerHTML">
Resend
</button>
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/revoke"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to revoke this invitation?">
Revoke
</button>
<button class="btn-small btn-secondary"
onclick="copyInvitationCode('{{.InvitationCode}}')">
Copy Code
</button>
{{else}}
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this invitation?">
Delete
</button>
{{end}}
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="empty-state">No invitations found.</td>
</tr>
{{end}}
{{if .HasMore}}
<tr>
<td colspan="7" class="load-more">
<button hx-get="/invitations/search?filter={{.EmailFilter}}&status_filter={{.StatusFilter}}&offset={{.NextOffset}}"
hx-target="closest tr"
hx-swap="outerHTML"
class="btn-small">
Load More
</button>
</td>
</tr>
{{end}}
@@ -14,6 +14,7 @@
<li class="brand">Eagle Admin <span class="build-info">{{if .BuildInfo.GitCommit}}({{.BuildInfo.GitCommit}}{{if .BuildInfo.BuildTime}}, {{.BuildInfo.BuildTime}}{{end}}){{end}}</span></li>
<li><a href="/">Games</a></li>
<li><a href="/users">Users</a></li>
<li><a href="/invitations">Invitations</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/health">Health</a></li>
<li id="jfr-controls" hx-get="/jfr/status" hx-trigger="load" hx-swap="innerHTML">
@@ -65,6 +65,13 @@
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
Edit
</button>
<button class="btn-small btn-danger"
hx-post="/users/{{.UserID}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this user? This cannot be undone.">
Delete
</button>
</td>
</tr>
{{end}}
@@ -259,6 +266,22 @@ function saveUserChanges() {
font-size: 0.85rem;
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.user-actions {
display: flex;
gap: 0.25rem;
}
#edit-modal article {
max-width: 500px;
}
@@ -30,6 +30,13 @@
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
Edit
</button>
<button class="btn-small btn-danger"
hx-post="/users/{{.UserID}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this user? This cannot be undone.">
Delete
</button>
</td>
</tr>
{{end}}
@@ -5,9 +5,12 @@ go_library(
srcs = [
"admin_handlers.go",
"handlers.go",
"invitation_handlers.go",
"invitations.go",
"jwt.go",
"main.go",
"oauth.go",
"sendgrid.go",
"users.go",
],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/authservice",
@@ -16,13 +16,17 @@ import (
// AdminHandler implements the Admin gRPC service
type AdminHandler struct {
adminpb.UnimplementedAdminServer
userService *UserService
userService *UserService
invitationService *InvitationService
emailService *EmailService
}
// NewAdminHandler creates a new AdminHandler
func NewAdminHandler(userService *UserService) *AdminHandler {
func NewAdminHandler(userService *UserService, invitationService *InvitationService, emailService *EmailService) *AdminHandler {
return &AdminHandler{
userService: userService,
userService: userService,
invitationService: invitationService,
emailService: emailService,
}
}
@@ -167,3 +171,230 @@ func (h *AdminHandler) userToAdminInfo(user *userpb.User) *adminpb.AdminUserInfo
return info
}
// ============== Invitation Management ==============
// CreateInvitation creates a new invitation and sends email
func (h *AdminHandler) CreateInvitation(ctx context.Context, req *adminpb.CreateInvitationRequest) (*adminpb.CreateInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] CreateInvitation called by %s for email=%s", adminUserID, req.Email)
// Create invitation
invitation, err := h.invitationService.CreateInvitation(req.Email, adminUserID, int(req.ExpiresInDays))
if err != nil {
return &adminpb.CreateInvitationResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
// Send invitation email
if err := h.emailService.SendInvitation(invitation); err != nil {
log.Printf("[Admin] Failed to send invitation email: %v", err)
// Don't fail the request - invitation was created, just email failed
// We can add a flag to indicate email send status if needed
}
return &adminpb.CreateInvitationResponse{
Success: true,
Invitation: h.invitationToInfo(invitation),
}, nil
}
// ListInvitations returns a paginated list of invitations
func (h *AdminHandler) ListInvitations(ctx context.Context, req *adminpb.ListInvitationsRequest) (*adminpb.ListInvitationsResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] ListInvitations called by %s, status=%v, email=%q, offset=%d, limit=%d",
adminUserID, req.StatusFilter, req.EmailFilter, req.Offset, req.Limit)
// Convert admin proto status to internal proto status
internalStatus := h.adminStatusToInternal(req.StatusFilter)
invitations, totalCount := h.invitationService.ListInvitations(
internalStatus,
req.EmailFilter,
int(req.Offset),
int(req.Limit),
)
var result []*adminpb.InvitationInfo
for _, inv := range invitations {
result = append(result, h.invitationToInfo(inv))
}
return &adminpb.ListInvitationsResponse{
Invitations: result,
TotalCount: int32(totalCount),
}, nil
}
// RevokeInvitation revokes an invitation
func (h *AdminHandler) RevokeInvitation(ctx context.Context, req *adminpb.RevokeInvitationRequest) (*adminpb.RevokeInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] RevokeInvitation called by %s for code=%s", adminUserID, req.InvitationCode[:8]+"...")
if err := h.invitationService.Revoke(req.InvitationCode); err != nil {
return &adminpb.RevokeInvitationResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
return &adminpb.RevokeInvitationResponse{
Success: true,
}, nil
}
// ResendInvitation resends the invitation email
func (h *AdminHandler) ResendInvitation(ctx context.Context, req *adminpb.ResendInvitationRequest) (*adminpb.ResendInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] ResendInvitation called by %s for code=%s", adminUserID, req.InvitationCode[:8]+"...")
// Find the invitation
invitation := h.invitationService.FindByCode(req.InvitationCode)
if invitation == nil {
return &adminpb.ResendInvitationResponse{
Success: false,
ErrorMessage: "invitation not found",
}, nil
}
// Check if invitation is still pending
if !h.invitationService.IsValidCode(req.InvitationCode) {
return &adminpb.ResendInvitationResponse{
Success: false,
ErrorMessage: "invitation is no longer valid (expired, redeemed, or revoked)",
}, nil
}
// Resend email
if err := h.emailService.SendInvitation(invitation); err != nil {
return &adminpb.ResendInvitationResponse{
Success: false,
ErrorMessage: "failed to send email: " + err.Error(),
}, nil
}
return &adminpb.ResendInvitationResponse{
Success: true,
}, nil
}
// invitationToInfo converts an internal Invitation to admin InvitationInfo
func (h *AdminHandler) invitationToInfo(inv *userpb.Invitation) *adminpb.InvitationInfo {
info := &adminpb.InvitationInfo{
InvitationCode: inv.GetInvitationCode(),
Email: inv.GetEmail(),
CreatedByUserId: inv.GetCreatedByUserId(),
CreatedAt: inv.GetCreatedAt(),
ExpiresAt: inv.GetExpiresAt(),
Status: h.internalStatusToAdmin(inv.GetStatus()),
RedeemedByUserId: inv.GetRedeemedByUserId(),
RedeemedAt: inv.GetRedeemedAt(),
}
// Resolve display names
if inv.GetCreatedByUserId() != "" {
if user := h.userService.FindByUserID(inv.GetCreatedByUserId()); user != nil {
info.CreatedByDisplayName = user.GetDisplayName()
}
}
if inv.GetRedeemedByUserId() != "" {
if user := h.userService.FindByUserID(inv.GetRedeemedByUserId()); user != nil {
info.RedeemedByDisplayName = user.GetDisplayName()
}
}
return info
}
// Convert between admin and internal proto status enums
func (h *AdminHandler) adminStatusToInternal(status adminpb.InvitationStatus) userpb.InvitationStatus {
switch status {
case adminpb.InvitationStatus_INVITATION_STATUS_PENDING:
return userpb.InvitationStatus_INVITATION_STATUS_PENDING
case adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return userpb.InvitationStatus_INVITATION_STATUS_REDEEMED
case adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
case adminpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return userpb.InvitationStatus_INVITATION_STATUS_REVOKED
default:
return userpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
}
}
func (h *AdminHandler) internalStatusToAdmin(status userpb.InvitationStatus) adminpb.InvitationStatus {
switch status {
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
return adminpb.InvitationStatus_INVITATION_STATUS_PENDING
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return adminpb.InvitationStatus_INVITATION_STATUS_REVOKED
default:
return adminpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
}
}
// DeleteUser permanently deletes a user
func (h *AdminHandler) DeleteUser(ctx context.Context, req *adminpb.DeleteUserRequest) (*adminpb.DeleteUserResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] DeleteUser called by %s for user %s", adminUserID, req.UserId)
// Prevent self-deletion
if req.UserId == adminUserID {
return &adminpb.DeleteUserResponse{
Success: false,
ErrorMessage: "cannot delete your own account",
}, nil
}
if err := h.userService.Delete(req.UserId); err != nil {
return &adminpb.DeleteUserResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
return &adminpb.DeleteUserResponse{
Success: true,
}, nil
}
// DeleteInvitation permanently deletes an invitation (only non-pending)
func (h *AdminHandler) DeleteInvitation(ctx context.Context, req *adminpb.DeleteInvitationRequest) (*adminpb.DeleteInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] DeleteInvitation called by %s for code=%s...", adminUserID, req.InvitationCode[:8])
if err := h.invitationService.Delete(req.InvitationCode); err != nil {
return &adminpb.DeleteInvitationResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
return &adminpb.DeleteInvitationResponse{
Success: true,
}, nil
}
+54 -8
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"log"
"os"
"strings"
"time"
@@ -12,6 +13,13 @@ import (
"google.golang.org/grpc/status"
)
// isInvitationRequired returns true if new users must provide invitation codes.
// Controlled by REQUIRE_INVITATION_CODE env var (defaults to false).
func isInvitationRequired() bool {
val := os.Getenv("REQUIRE_INVITATION_CODE")
return val == "true" || val == "1"
}
// extractAccessToken extracts the JWT access token from gRPC metadata
func extractAccessToken(ctx context.Context) (string, error) {
md, ok := metadata.FromIncomingContext(ctx)
@@ -41,15 +49,17 @@ func extractAccessToken(ctx context.Context) (string, error) {
// AuthHandler implements the Auth gRPC service
type AuthHandler struct {
auth.UnimplementedAuthServer
oauthSvc *OAuthService
userService *UserService
oauthSvc *OAuthService
userService *UserService
invitationService *InvitationService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService) *AuthHandler {
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitationService *InvitationService) *AuthHandler {
return &AuthHandler{
oauthSvc: oauthSvc,
userService: userService,
oauthSvc: oauthSvc,
userService: userService,
invitationService: invitationService,
}
}
@@ -57,7 +67,7 @@ func NewAuthHandler(oauthSvc *OAuthService, userService *UserService) *AuthHandl
func (h *AuthHandler) GetOAuthUrl(ctx context.Context, req *auth.GetOAuthUrlRequest) (*auth.GetOAuthUrlResponse, error) {
provider := providerToString(req.Provider)
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl)
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl, req.InvitationCode)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to get auth URL: %v", err)
}
@@ -94,13 +104,49 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
// Success - find or create user locally
if result.Success {
user, isNewUser := h.userService.FindOrCreateUser(
// Check if user already exists (for grandfathering)
existingUser := h.userService.FindByOAuthIdentity(result.Provider, result.UserInfo.ID)
isNewUser := existingUser == nil
// For new users, validate invitation code (if feature is enabled)
if isNewUser && isInvitationRequired() {
invitationCode := result.InvitationCode
// If no invitation code provided, require one
if invitationCode == "" {
log.Printf("New user %s attempted login without invitation code", result.UserInfo.Email)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_INVITATION_REQUIRED,
ErrorMessage: "An invitation code is required to create a new account",
}, nil
}
// Validate invitation code (don't redeem yet - we'll do that after user creation succeeds)
if !h.invitationService.IsValidCode(invitationCode) {
log.Printf("New user %s provided invalid invitation code", result.UserInfo.Email)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
ErrorMessage: "Invalid or expired invitation code",
}, nil
}
}
// Find or create user
user, wasCreated := h.userService.FindOrCreateUser(
result.Provider,
result.UserInfo.ID,
result.UserInfo.Email,
result.UserInfo.AvatarURL,
)
// If user was created (new user), redeem the invitation
if wasCreated && result.InvitationCode != "" {
if _, err := h.invitationService.ValidateAndRedeem(result.InvitationCode, user.UserId); err != nil {
// Log the error but don't fail - user is already created
log.Printf("Warning: failed to redeem invitation code for new user %s: %v", user.UserId, err)
}
}
// Create JWT tokens
accessToken, err := CreateAccessToken(user.UserId, user.DisplayName, user.IsAdmin)
if err != nil {
@@ -134,7 +180,7 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
AvatarUrl: user.AvatarUrl,
Provider: stringToProvider(result.Provider),
},
IsNewUser: isNewUser,
IsNewUser: wasCreated,
}, nil
}
@@ -0,0 +1,481 @@
// Package main provides HTTP handlers for invitation landing pages.
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
"time"
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
)
// InvitationHTTPHandler handles HTTP requests for invitation pages
type InvitationHTTPHandler struct {
invitationService *InvitationService
installerURL string
macInstallerURL string
}
// NewInvitationHTTPHandler creates a new invitation HTTP handler
func NewInvitationHTTPHandler(invitationService *InvitationService) *InvitationHTTPHandler {
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
}
macInstallerURL := os.Getenv("MAC_INSTALLER_URL")
if macInstallerURL == "" {
macInstallerURL = "https://assets.eagle0.net/mac/builds/eagle0-latest.zip"
}
return &InvitationHTTPHandler{
invitationService: invitationService,
installerURL: installerURL,
macInstallerURL: macInstallerURL,
}
}
// RegisterRoutes registers the invitation HTTP routes
func (h *InvitationHTTPHandler) RegisterRoutes() {
http.HandleFunc("/invite/", h.handleInvite)
}
// handleInvite routes requests to the appropriate handler
func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Request) {
// Parse path: /invite/{code} or /invite/{code}/install.bat or /invite/{code}/install.sh
path := strings.TrimPrefix(r.URL.Path, "/invite/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "Invalid invitation URL", http.StatusBadRequest)
return
}
code := parts[0]
if len(parts) == 2 && parts[1] == "install.bat" {
h.handleInstallBat(w, r, code)
} else if len(parts) == 2 && parts[1] == "install.sh" {
h.handleInstallSh(w, r, code)
} else if len(parts) == 1 {
h.handleLandingPage(w, r, code)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
}
// isMacUserAgent returns true if the User-Agent indicates a Mac browser
func isMacUserAgent(userAgent string) bool {
ua := strings.ToLower(userAgent)
return strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os x")
}
// handleLandingPage serves the invitation landing page
func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http.Request, code string) {
invitation := h.invitationService.FindByCode(code)
isMac := isMacUserAgent(r.UserAgent())
data := struct {
Valid bool
Code string
ExpiresAt string
ErrorMessage string
InstallBatURL string
InstallerURL string
IsMac bool
InstallShURL string
MacInstallerURL string
}{
Code: code,
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
InstallerURL: h.installerURL,
IsMac: isMac,
InstallShURL: fmt.Sprintf("/invite/%s/install.sh", code),
MacInstallerURL: h.macInstallerURL,
}
if invitation == nil {
data.Valid = false
data.ErrorMessage = "This invitation code is invalid or does not exist."
} else {
switch invitation.Status {
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
// Check if expired by date
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
data.Valid = false
data.ErrorMessage = "This invitation has expired."
} else {
data.Valid = true
if invitation.ExpiresAt != nil {
data.ExpiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
}
}
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
data.Valid = false
data.ErrorMessage = "This invitation has already been used."
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
data.Valid = false
data.ErrorMessage = "This invitation has expired."
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
data.Valid = false
data.ErrorMessage = "This invitation has been revoked."
default:
data.Valid = false
data.ErrorMessage = "This invitation is not valid."
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := landingPageTemplate.Execute(w, data); err != nil {
log.Printf("[InviteHTTP] Failed to render landing page: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// handleInstallBat serves the Windows batch installer script
func (h *InvitationHTTPHandler) handleInstallBat(w http.ResponseWriter, r *http.Request, code string) {
// Validate the code exists and is valid
if !h.invitationService.IsValidCode(code) {
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
return
}
// Generate batch script
script := fmt.Sprintf(`@echo off
echo.
echo ====================================
echo Eagle0 Installer
echo ====================================
echo.
echo Downloading Eagle0 installer...
echo.
curl -L -o "%%TEMP%%\EagleInstaller.exe" "%s"
if %%ERRORLEVEL%% neq 0 (
echo.
echo Failed to download installer. Please check your internet connection.
echo You can also download manually from: %s
echo.
pause
exit /b 1
)
echo.
echo Starting installer...
echo.
start "" "%%TEMP%%\EagleInstaller.exe" --code=%s
echo.
echo The installer should now be running.
echo You can close this window.
echo.
timeout /t 5
`, h.installerURL, h.installerURL, code)
w.Header().Set("Content-Type", "application/x-bat")
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.bat")
w.Write([]byte(script))
log.Printf("[InviteHTTP] Served install.bat for code %s...", code[:8])
}
// handleInstallSh serves the Mac shell installer script
func (h *InvitationHTTPHandler) handleInstallSh(w http.ResponseWriter, r *http.Request, code string) {
// Validate the code exists and is valid
if !h.invitationService.IsValidCode(code) {
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
return
}
// Generate shell script
script := fmt.Sprintf(`#!/bin/bash
#
# Eagle0 Mac Installer
# This script downloads Eagle0 and sets up your invitation code.
#
set -e
echo ""
echo "===================================="
echo " Eagle0 Mac Installer"
echo "===================================="
echo ""
# Create app support directory
APP_SUPPORT_DIR="$HOME/Library/Application Support/eagle0"
mkdir -p "$APP_SUPPORT_DIR"
# Write invitation code
echo "Setting up invitation code..."
cat > "$APP_SUPPORT_DIR/invitation.json" << 'INVITATION_EOF'
{"invitationCode": "%s"}
INVITATION_EOF
echo "Invitation code saved."
echo ""
# Download location
DOWNLOAD_DIR="$HOME/Downloads"
ZIP_PATH="$DOWNLOAD_DIR/eagle0.zip"
APP_PATH="/Applications/eagle0.app"
echo "Downloading Eagle0..."
curl -L -o "$ZIP_PATH" "%s"
echo ""
echo "Extracting to /Applications..."
# Remove old version if exists
if [ -d "$APP_PATH" ]; then
echo "Removing previous version..."
rm -rf "$APP_PATH"
fi
# Unzip to Applications
unzip -q "$ZIP_PATH" -d /Applications/
# Clean up zip
rm "$ZIP_PATH"
# Remove quarantine attribute (app is notarized but downloaded via curl)
xattr -dr com.apple.quarantine "$APP_PATH" 2>/dev/null || true
echo ""
echo "===================================="
echo " Installation Complete!"
echo "===================================="
echo ""
echo "Eagle0 has been installed to /Applications/eagle0.app"
echo "Your invitation code has been saved."
echo ""
echo "Starting Eagle0..."
open "$APP_PATH"
`, code, h.macInstallerURL)
w.Header().Set("Content-Type", "application/x-sh")
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.sh")
w.Write([]byte(script))
log.Printf("[InviteHTTP] Served install.sh for code %s...", code[:8])
}
var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eagle0 Invitation</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-top: 40px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 48px;
font-weight: bold;
color: #1a5f7a;
margin-bottom: 10px;
}
h1 {
font-size: 24px;
color: #333;
margin: 0;
}
.error-box {
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
padding: 20px;
text-align: center;
color: #c00;
}
.content {
text-align: center;
}
.button {
display: inline-block;
background: #1a5f7a;
color: white !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 8px;
font-weight: bold;
font-size: 18px;
margin: 20px 0;
transition: background 0.2s;
}
.button:hover {
background: #154d62;
}
.instructions {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: left;
}
.instructions h3 {
margin-top: 0;
color: #1a5f7a;
}
.instructions ol {
margin: 0;
padding-left: 20px;
}
.instructions li {
margin: 10px 0;
}
.code-box {
background: #e9ecef;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 12px;
font-family: monospace;
font-size: 14px;
word-break: break-all;
margin: 15px 0;
}
.manual-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.manual-section h3 {
color: #666;
font-size: 14px;
}
.expires {
color: #856404;
background: #fff3cd;
padding: 10px;
border-radius: 4px;
margin-top: 20px;
font-size: 14px;
}
.footer {
text-align: center;
color: #999;
font-size: 12px;
margin-top: 30px;
}
code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
.other-platform {
margin-top: 20px;
text-align: center;
color: #666;
}
.other-platform a {
color: #1a5f7a;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Eagle0</div>
{{if .Valid}}
<h1>You've been invited!</h1>
{{else}}
<h1>Invitation Error</h1>
{{end}}
</div>
{{if .Valid}}
<div class="content">
<p>You've been invited to play Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
{{if .IsMac}}
<a href="{{.InstallShURL}}" class="button">Download for Mac</a>
<div class="instructions">
<h3>How to install on Mac:</h3>
<ol>
<li>Click the "Download for Mac" button above</li>
<li>Open Terminal and navigate to your Downloads folder:<br>
<code>cd ~/Downloads</code></li>
<li>Run the installer script:<br>
<code>bash eagle0-install.sh</code></li>
<li>Eagle0 will be installed to /Applications and launched automatically</li>
</ol>
</div>
{{else}}
<a href="{{.InstallBatURL}}" class="button">Download for Windows</a>
<div class="instructions">
<h3>How to install on Windows:</h3>
<ol>
<li>Click the "Download for Windows" button above</li>
<li>Open the downloaded <strong>eagle0-install.bat</strong> file</li>
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
<li>The installer will download and start automatically with your invitation code</li>
</ol>
</div>
{{end}}
{{if .ExpiresAt}}
<div class="expires">
This invitation expires on {{.ExpiresAt}}.
</div>
{{end}}
<div class="manual-section">
{{if .IsMac}}
<h3>Alternative: Manual Installation</h3>
<p>If the script doesn't work, you can <a href="{{.MacInstallerURL}}">download the ZIP manually</a>, extract it to /Applications, and enter this code when prompted:</p>
{{else}}
<h3>Alternative: Manual Installation</h3>
<p>If the automatic installer doesn't work, you can <a href="{{.InstallerURL}}">download the installer manually</a> and enter this code when prompted:</p>
{{end}}
<div class="code-box">{{.Code}}</div>
</div>
<div class="other-platform">
{{if .IsMac}}
<p><small>Looking for Windows? <a href="{{.InstallBatURL}}">Download Windows installer</a></small></p>
{{else}}
<p><small>Looking for Mac? <a href="{{.InstallShURL}}">Download Mac installer</a></small></p>
{{end}}
</div>
</div>
{{else}}
<div class="error-box">
<p>{{.ErrorMessage}}</p>
<p>Please contact the person who invited you for a new invitation.</p>
</div>
{{end}}
<div class="footer">
<p>&copy; 2026 Eagle0</p>
</div>
</div>
</body>
</html>
`))
@@ -0,0 +1,405 @@
// Package main provides invitation management for the auth service.
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
)
const (
// InvitationCodeLength is the number of random bytes for invitation codes
InvitationCodeLength = 32
// DefaultExpirationDays is the default number of days until invitation expires
DefaultExpirationDays = 30
)
// InvitationService manages invitation persistence
type InvitationService struct {
mu sync.RWMutex
database *userpb.InvitationDatabase
dataPath string
lastModTime time.Time
}
// NewInvitationService creates a new invitation service with persistence
func NewInvitationService(dataDir string) (*InvitationService, error) {
// Ensure data directory exists
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
dataPath := filepath.Join(dataDir, "invitations.pb")
is := &InvitationService{
dataPath: dataPath,
database: &userpb.InvitationDatabase{
CodeIndex: make(map[string]int32),
},
}
// Try to load existing data
if err := is.loadFrom(dataPath); err == nil {
if info, err := os.Stat(dataPath); err == nil {
is.lastModTime = info.ModTime()
}
log.Printf("Loaded %d invitations from %s", len(is.database.Invitations), dataPath)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load invitation database: %w", err)
} else {
log.Printf("Starting with empty invitation database at %s", dataPath)
}
return is, nil
}
// loadFrom reads the database from a specific path
func (is *InvitationService) loadFrom(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
db := &userpb.InvitationDatabase{}
if err := proto.Unmarshal(data, db); err != nil {
return fmt.Errorf("failed to unmarshal database: %w", err)
}
// Ensure map is initialized
if db.CodeIndex == nil {
db.CodeIndex = make(map[string]int32)
}
is.database = db
return nil
}
// save writes the database to disk using atomic rename
func (is *InvitationService) save() error {
data, err := proto.Marshal(is.database)
if err != nil {
return fmt.Errorf("failed to marshal database: %w", err)
}
dir := filepath.Dir(is.dataPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
tmpPath := is.dataPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, is.dataPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
if info, err := os.Stat(is.dataPath); err == nil {
is.lastModTime = info.ModTime()
}
return nil
}
// generateCode generates a cryptographically secure invitation code
func generateCode() (string, error) {
b := make([]byte, InvitationCodeLength)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Use URL-safe base64 encoding without padding
return base64.RawURLEncoding.EncodeToString(b), nil
}
// CreateInvitation creates a new invitation and returns it
func (is *InvitationService) CreateInvitation(email, createdByUserID string, expiresInDays int) (*userpb.Invitation, error) {
is.mu.Lock()
defer is.mu.Unlock()
// Validate email (basic check)
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return nil, fmt.Errorf("invalid email address")
}
// Use default expiration if not specified
if expiresInDays <= 0 {
expiresInDays = DefaultExpirationDays
}
// Generate unique code
code, err := generateCode()
if err != nil {
return nil, err
}
now := time.Now()
expiresAt := now.AddDate(0, 0, expiresInDays)
invitation := &userpb.Invitation{
InvitationCode: code,
Email: strings.ToLower(email),
CreatedByUserId: createdByUserID,
CreatedAt: timestamppb.New(now),
ExpiresAt: timestamppb.New(expiresAt),
Status: userpb.InvitationStatus_INVITATION_STATUS_PENDING,
RedeemedByUserId: "",
RedeemedAt: nil,
}
// Add to database
idx := int32(len(is.database.Invitations))
is.database.Invitations = append(is.database.Invitations, invitation)
is.database.CodeIndex[code] = idx
if err := is.save(); err != nil {
return nil, fmt.Errorf("failed to save invitation: %w", err)
}
log.Printf("[Invitation] Created invitation for %s by user %s (expires %s)", email, createdByUserID, expiresAt.Format(time.RFC3339))
return invitation, nil
}
// FindByCode looks up an invitation by its code
func (is *InvitationService) FindByCode(code string) *userpb.Invitation {
is.mu.RLock()
defer is.mu.RUnlock()
if idx, ok := is.database.CodeIndex[code]; ok {
if int(idx) < len(is.database.Invitations) {
return is.database.Invitations[idx]
}
}
return nil
}
// ValidateAndRedeem validates an invitation code and marks it as redeemed
// Returns the invitation if valid, or an error describing why it's invalid
func (is *InvitationService) ValidateAndRedeem(code, redeemedByUserID string) (*userpb.Invitation, error) {
is.mu.Lock()
defer is.mu.Unlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return nil, fmt.Errorf("invalid invitation code")
}
invitation := is.database.Invitations[idx]
// Check status
switch invitation.Status {
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return nil, fmt.Errorf("invitation has already been used")
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return nil, fmt.Errorf("invitation has expired")
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return nil, fmt.Errorf("invitation has been revoked")
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
// Check if expired by date
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
is.database.Invitations[idx] = invitation
is.save() // Best effort save
return nil, fmt.Errorf("invitation has expired")
}
default:
return nil, fmt.Errorf("invitation is not valid")
}
// Mark as redeemed
now := timestamppb.Now()
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_REDEEMED
invitation.RedeemedByUserId = redeemedByUserID
invitation.RedeemedAt = now
is.database.Invitations[idx] = invitation
if err := is.save(); err != nil {
return nil, fmt.Errorf("failed to save redemption: %w", err)
}
log.Printf("[Invitation] Code %s redeemed by user %s", code[:8]+"...", redeemedByUserID)
return invitation, nil
}
// IsValidCode checks if a code is valid (pending and not expired)
// This is a non-mutating check for pre-validation
func (is *InvitationService) IsValidCode(code string) bool {
is.mu.RLock()
defer is.mu.RUnlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return false
}
invitation := is.database.Invitations[idx]
if invitation.Status != userpb.InvitationStatus_INVITATION_STATUS_PENDING {
return false
}
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
return false
}
return true
}
// Revoke revokes an invitation (prevents it from being used)
func (is *InvitationService) Revoke(code string) error {
is.mu.Lock()
defer is.mu.Unlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return fmt.Errorf("invitation not found")
}
invitation := is.database.Invitations[idx]
if invitation.Status == userpb.InvitationStatus_INVITATION_STATUS_REDEEMED {
return fmt.Errorf("cannot revoke an already-redeemed invitation")
}
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_REVOKED
is.database.Invitations[idx] = invitation
if err := is.save(); err != nil {
return fmt.Errorf("failed to save revocation: %w", err)
}
log.Printf("[Invitation] Code %s revoked", code[:8]+"...")
return nil
}
// ListInvitations returns invitations with optional filtering and pagination
func (is *InvitationService) ListInvitations(statusFilter userpb.InvitationStatus, emailFilter string, offset, limit int) ([]*userpb.Invitation, int) {
is.mu.RLock()
defer is.mu.RUnlock()
// Default and cap limit
if limit <= 0 {
limit = 50
}
if limit > 100 {
limit = 100
}
// Update expired status for pending invitations
now := time.Now()
for i, inv := range is.database.Invitations {
if inv.Status == userpb.InvitationStatus_INVITATION_STATUS_PENDING {
if inv.ExpiresAt != nil && inv.ExpiresAt.AsTime().Before(now) {
is.database.Invitations[i].Status = userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
}
}
}
// Filter invitations
emailFilterLower := strings.ToLower(emailFilter)
var filtered []*userpb.Invitation
for _, inv := range is.database.Invitations {
// Status filter (0 = all)
if statusFilter != userpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED && inv.Status != statusFilter {
continue
}
// Email filter
if emailFilter != "" && !strings.Contains(inv.Email, emailFilterLower) {
continue
}
filtered = append(filtered, inv)
}
totalCount := len(filtered)
// Apply pagination
if offset >= len(filtered) {
return nil, totalCount
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end], totalCount
}
// GetStats returns invitation statistics
func (is *InvitationService) GetStats() (pending, redeemed, expired, revoked int) {
is.mu.RLock()
defer is.mu.RUnlock()
now := time.Now()
for _, inv := range is.database.Invitations {
switch inv.Status {
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
// Check if actually expired
if inv.ExpiresAt != nil && inv.ExpiresAt.AsTime().Before(now) {
expired++
} else {
pending++
}
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
redeemed++
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
expired++
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
revoked++
}
}
return
}
// Delete permanently removes an invitation from the database.
// Only revoked, expired, or redeemed invitations can be deleted.
func (is *InvitationService) Delete(code string) error {
is.mu.Lock()
defer is.mu.Unlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return fmt.Errorf("invitation not found")
}
invitation := is.database.Invitations[idx]
// Only allow deletion of non-pending invitations
if invitation.Status == userpb.InvitationStatus_INVITATION_STATUS_PENDING {
// Check if actually expired
if invitation.ExpiresAt == nil || !invitation.ExpiresAt.AsTime().Before(time.Now()) {
return fmt.Errorf("cannot delete a pending invitation (revoke it first)")
}
}
// Remove from code index
delete(is.database.CodeIndex, code)
// Remove from invitations slice
is.database.Invitations = append(is.database.Invitations[:idx], is.database.Invitations[idx+1:]...)
// Rebuild code index since array indices have shifted
is.database.CodeIndex = make(map[string]int32)
for i, inv := range is.database.Invitations {
is.database.CodeIndex[inv.InvitationCode] = int32(i)
}
if err := is.save(); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
log.Printf("[Invitation] Deleted invitation for %s (code %s...)", invitation.Email, code[:8])
return nil
}
+24 -3
View File
@@ -101,14 +101,31 @@ func main() {
log.Printf("Legacy migration path configured: %s", actualLegacyDataDir)
}
// Create invitation service for invitation-based registration
invitationService, err := NewInvitationService(actualDataDir)
if err != nil {
log.Fatalf("Failed to create invitation service: %v", err)
}
log.Printf("Invitation service initialized")
// Log invitation requirement status
if os.Getenv("REQUIRE_INVITATION_CODE") == "true" || os.Getenv("REQUIRE_INVITATION_CODE") == "1" {
log.Printf("Invitation codes REQUIRED for new accounts (REQUIRE_INVITATION_CODE=true)")
} else {
log.Printf("Invitation codes NOT required for new accounts (set REQUIRE_INVITATION_CODE=true to enable)")
}
// Create email service for sending invitation emails
emailService := NewEmailService()
// Create OAuth service
oauthSvc := NewOAuthService(getOAuthConfigs())
// Create auth service handler
authHandler := NewAuthHandler(oauthSvc, userService)
authHandler := NewAuthHandler(oauthSvc, userService, invitationService)
// Create admin service handler
adminHandler := NewAdminHandler(userService)
adminHandler := NewAdminHandler(userService, invitationService, emailService)
// Start gRPC server
grpcLis, err := net.Listen("tcp", fmt.Sprintf(":%d", actualGRPCPort))
@@ -127,13 +144,17 @@ func main() {
}
}()
// Start HTTP server for OAuth callbacks
// Start HTTP server for OAuth callbacks and invitation pages
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "OK")
})
// Register invitation landing page routes
inviteHandler := NewInvitationHTTPHandler(invitationService)
inviteHandler.RegisterRoutes()
log.Printf("HTTP server listening on port %d", actualHTTPPort)
if err := http.ListenAndServe(fmt.Sprintf(":%d", actualHTTPPort), nil); err != nil {
log.Fatalf("HTTP server failed: %v", err)
+20 -15
View File
@@ -27,9 +27,10 @@ type OAuthProviderConfig struct {
// OAuthState represents a pending OAuth session
type OAuthState struct {
Provider string
CreatedAt time.Time
ReturnURL string // Optional: URL to redirect to after callback (for web clients)
Provider string
CreatedAt time.Time
ReturnURL string // Optional: URL to redirect to after callback (for web clients)
InvitationCode string // Optional: Invitation code for new user registration
}
// ProviderUserInfo holds user info from an OAuth provider
@@ -42,10 +43,11 @@ type ProviderUserInfo struct {
// OAuthResult represents the result of an OAuth flow
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
InvitationCode string // Invitation code from the original request
}
// OAuthService manages OAuth state and handles callbacks
@@ -92,7 +94,8 @@ func getCallbackURL() string {
// GetAuthURL generates an OAuth authorization URL
// returnURL is optional - if set, the callback will redirect there instead of showing "close window"
func (s *OAuthService) GetAuthURL(provider string, returnURL string) (authURL string, state string, err error) {
// invitationCode is optional - if set, it will be stored with the OAuth state for new user validation
func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationCode string) (authURL string, state string, err error) {
config, ok := s.configs[provider]
if !ok {
return "", "", fmt.Errorf("unsupported provider: %s", provider)
@@ -100,12 +103,13 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string) (authURL st
state = uuid.New().String()
s.pendingOAuth.Store(state, OAuthState{
Provider: provider,
CreatedAt: time.Now(),
ReturnURL: returnURL,
Provider: provider,
CreatedAt: time.Now(),
ReturnURL: returnURL,
InvitationCode: invitationCode,
})
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s", state, provider, returnURL)
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
params := url.Values{}
params.Set("client_id", config.ClientID)
@@ -206,9 +210,10 @@ func (s *OAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) {
// Mark as completed
s.pendingOAuth.Delete(state)
s.completedOAuth.Store(state, OAuthResult{
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
InvitationCode: oauthState.InvitationCode,
})
log.Printf("OAuth callback: SUCCESS state=%s user=%s returnURL=%s", state, userInfo.Username, oauthState.ReturnURL)
@@ -36,7 +36,7 @@ func TestGetAuthURL(t *testing.T) {
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord", "")
url, state, err := svc.GetAuthURL("discord", "", "")
if err != nil {
t.Fatalf("GetAuthURL failed: %v", err)
}
@@ -58,7 +58,7 @@ func TestGetAuthURL_UnknownProvider(t *testing.T) {
configs := map[string]OAuthProviderConfig{}
svc := NewOAuthService(configs)
_, _, err := svc.GetAuthURL("unknown", "")
_, _, err := svc.GetAuthURL("unknown", "", "")
if err == nil {
t.Error("Expected error for unknown provider")
}
@@ -76,7 +76,7 @@ func TestGetAuthURL_EmptyClientID(t *testing.T) {
}
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord", "")
url, state, err := svc.GetAuthURL("discord", "", "")
// No error expected - the provider exists, it just has empty client ID
if err != nil {
t.Errorf("Unexpected error: %v", err)
@@ -105,7 +105,7 @@ func TestIsPending(t *testing.T) {
}
// Create a pending state
_, state, _ := svc.GetAuthURL("discord", "")
_, state, _ := svc.GetAuthURL("discord", "", "")
if !svc.IsPending(state) {
t.Error("State should be pending after GetAuthURL")
@@ -122,7 +122,7 @@ func TestCheckStatus_Pending(t *testing.T) {
}
svc := NewOAuthService(configs)
_, state, _ := svc.GetAuthURL("discord", "")
_, state, _ := svc.GetAuthURL("discord", "", "")
result := svc.CheckStatus(state)
if result.Success {
@@ -157,7 +157,7 @@ func TestCheckStatus_Completed(t *testing.T) {
svc := NewOAuthService(configs)
// Create a pending state
_, state, _ := svc.GetAuthURL("discord", "")
_, state, _ := svc.GetAuthURL("discord", "", "")
// Simulate completion by directly storing in completedOAuth
testResult := OAuthResult{
@@ -194,7 +194,7 @@ func TestCheckStatus_Error(t *testing.T) {
svc := NewOAuthService(configs)
// Create a pending state
_, state, _ := svc.GetAuthURL("discord", "")
_, state, _ := svc.GetAuthURL("discord", "", "")
// Simulate error by storing error result
errorResult := OAuthResult{
@@ -223,7 +223,7 @@ func TestPendingOAuthExpiry(t *testing.T) {
}
svc := NewOAuthService(configs)
_, state, _ := svc.GetAuthURL("discord", "")
_, state, _ := svc.GetAuthURL("discord", "", "")
// Verify the pending entry has a creation time
value, ok := svc.pendingOAuth.Load(state)
@@ -296,9 +296,9 @@ func TestMultiplePendingStates(t *testing.T) {
svc := NewOAuthService(configs)
// Create multiple pending states
_, state1, _ := svc.GetAuthURL("discord", "")
_, state2, _ := svc.GetAuthURL("discord", "")
_, state3, _ := svc.GetAuthURL("discord", "")
_, state1, _ := svc.GetAuthURL("discord", "", "")
_, state2, _ := svc.GetAuthURL("discord", "", "")
_, state3, _ := svc.GetAuthURL("discord", "", "")
// All should be unique
if state1 == state2 || state2 == state3 || state1 == state3 {
@@ -0,0 +1,455 @@
// Package main provides email sending via Fastmail JMAP API for the auth service.
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"time"
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
)
const (
fastmailSessionURL = "https://api.fastmail.com/jmap/session"
fastmailAPIURL = "https://api.fastmail.com/jmap/api/"
)
// EmailService handles sending emails via Fastmail JMAP API
type EmailService struct {
apiToken string
fromEmail string
fromName string
installerURL string
serverBaseURL string
httpClient *http.Client
enabled bool
// JMAP session info (populated on first send)
accountID string
identityID string
draftsID string
}
// NewEmailService creates a new email service
// Returns a disabled service if FASTMAIL_API_TOKEN is not set
func NewEmailService() *EmailService {
apiToken := os.Getenv("FASTMAIL_API_TOKEN")
fromEmail := os.Getenv("FASTMAIL_FROM_EMAIL")
fromName := os.Getenv("FASTMAIL_FROM_NAME")
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
serverBaseURL := os.Getenv("SERVER_BASE_URL")
if fromEmail == "" {
fromEmail = "noreply@eagle0.net"
}
if fromName == "" {
fromName = "Eagle0 Game"
}
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
}
if serverBaseURL == "" {
serverBaseURL = "https://prod.eagle0.net"
}
enabled := apiToken != ""
if !enabled {
log.Printf("WARNING: FASTMAIL_API_TOKEN not set - email sending disabled")
} else {
log.Printf("Email service initialized (JMAP: api.fastmail.com, from: %s <%s>)", fromName, fromEmail)
}
return &EmailService{
apiToken: apiToken,
fromEmail: fromEmail,
fromName: fromName,
installerURL: installerURL,
serverBaseURL: serverBaseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
enabled: enabled,
}
}
// IsEnabled returns true if email sending is configured
func (e *EmailService) IsEnabled() bool {
return e.enabled
}
// SendInvitation sends an invitation email
func (e *EmailService) SendInvitation(invitation *userpb.Invitation) error {
if !e.enabled {
log.Printf("[Email] Would send invitation to %s (email disabled)", invitation.Email)
return nil
}
// Initialize JMAP session if needed
if e.accountID == "" {
if err := e.initSession(); err != nil {
return fmt.Errorf("failed to initialize JMAP session: %w", err)
}
}
// Build invite page URL
invitePageURL := fmt.Sprintf("%s/invite/%s", e.serverBaseURL, invitation.InvitationCode)
// Build email content
subject := "You've been invited to play Eagle0!"
htmlContent, err := e.buildInvitationHTML(invitation, invitePageURL)
if err != nil {
return fmt.Errorf("failed to build email content: %w", err)
}
textContent := e.buildInvitationText(invitation, invitePageURL)
// Send via JMAP
if err := e.sendEmail(invitation.Email, subject, htmlContent, textContent); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
log.Printf("[Email] Sent invitation email to %s", invitation.Email)
return nil
}
// initSession fetches JMAP session info (account ID, identity ID, drafts mailbox ID)
func (e *EmailService) initSession() error {
// Get session
req, err := http.NewRequest("GET", fastmailSessionURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+e.apiToken)
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("session request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("session request failed: %d: %s", resp.StatusCode, string(body))
}
var session struct {
PrimaryAccounts map[string]string `json:"primaryAccounts"`
}
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return fmt.Errorf("failed to decode session: %w", err)
}
e.accountID = session.PrimaryAccounts["urn:ietf:params:jmap:mail"]
if e.accountID == "" {
return fmt.Errorf("no mail account found in session")
}
log.Printf("[Email] JMAP account ID: %s", e.accountID)
// Get identity ID and drafts mailbox ID
if err := e.fetchIdentityAndDrafts(); err != nil {
return err
}
return nil
}
// fetchIdentityAndDrafts gets the identity ID and drafts mailbox ID
func (e *EmailService) fetchIdentityAndDrafts() error {
payload := map[string]interface{}{
"using": []string{
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
},
"methodCalls": []interface{}{
[]interface{}{"Identity/get", map[string]interface{}{"accountId": e.accountID}, "0"},
[]interface{}{"Mailbox/query", map[string]interface{}{
"accountId": e.accountID,
"filter": map[string]string{"role": "drafts"},
}, "1"},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", fastmailAPIURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+e.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("JMAP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("JMAP request failed: %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
MethodResponses [][]interface{} `json:"methodResponses"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode JMAP response: %w", err)
}
// Extract identity ID from first response
if len(result.MethodResponses) > 0 {
if data, ok := result.MethodResponses[0][1].(map[string]interface{}); ok {
if list, ok := data["list"].([]interface{}); ok && len(list) > 0 {
if identity, ok := list[0].(map[string]interface{}); ok {
if id, ok := identity["id"].(string); ok {
e.identityID = id
}
// Use the identity's email if we don't have one configured
if email, ok := identity["email"].(string); ok && e.fromEmail == "noreply@eagle0.net" {
e.fromEmail = email
}
}
}
}
}
// Extract drafts mailbox ID from second response
if len(result.MethodResponses) > 1 {
if data, ok := result.MethodResponses[1][1].(map[string]interface{}); ok {
if ids, ok := data["ids"].([]interface{}); ok && len(ids) > 0 {
if id, ok := ids[0].(string); ok {
e.draftsID = id
}
}
}
}
if e.identityID == "" {
return fmt.Errorf("failed to get identity ID")
}
if e.draftsID == "" {
return fmt.Errorf("failed to get drafts mailbox ID")
}
log.Printf("[Email] JMAP identity: %s, drafts: %s", e.identityID, e.draftsID)
return nil
}
func (e *EmailService) buildInvitationHTML(invitation *userpb.Invitation, invitePageURL string) (string, error) {
const htmlTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eagle0 Invitation</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.logo { font-size: 32px; font-weight: bold; color: #1a5f7a; }
.content { background: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0; }
.button { display: inline-block; background: #1a5f7a; color: white !important; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.button:hover { background: #154d62; }
.footer { text-align: center; color: #6c757d; font-size: 12px; margin-top: 30px; }
.expires { color: #856404; background: #fff3cd; padding: 10px; border-radius: 4px; margin-top: 15px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">Eagle0</div>
</div>
<div class="content">
<h2>You've been invited!</h2>
<p>Someone has invited you to join Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
<p style="text-align: center;">
<a href="{{.InvitePageURL}}" class="button">Accept Invitation</a>
</p>
<p style="text-align: center; color: #666;">Click the button above to download and install Eagle0.</p>
<div class="expires">
This invitation expires on {{.ExpiresAt}}.
</div>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
If the button doesn't work, copy this link into your browser:<br>
<a href="{{.InvitePageURL}}" style="color: #1a5f7a;">{{.InvitePageURL}}</a>
</p>
</div>
<div class="footer">
<p>This is an automated message. If you didn't expect this invitation, you can safely ignore it.</p>
</div>
</body>
</html>`
tmpl, err := template.New("invitation").Parse(htmlTemplate)
if err != nil {
return "", err
}
var expiresAt string
if invitation.ExpiresAt != nil {
expiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
} else {
expiresAt = "N/A"
}
data := map[string]string{
"InvitePageURL": invitePageURL,
"ExpiresAt": expiresAt,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func (e *EmailService) buildInvitationText(invitation *userpb.Invitation, invitePageURL string) string {
var expiresAt string
if invitation.ExpiresAt != nil {
expiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
} else {
expiresAt = "N/A"
}
return fmt.Sprintf(`You've been invited to play Eagle0!
Someone has invited you to join Eagle0, a strategic turn-based game with tactical hex-based combat.
Click here to accept your invitation and download the game:
%s
This invitation expires on %s.
---
This is an automated message. If you didn't expect this invitation, you can safely ignore it.
`, invitePageURL, expiresAt)
}
// sendEmail sends an email via Fastmail JMAP API
func (e *EmailService) sendEmail(to, subject, htmlContent, textContent string) error {
// Create email and submit in one request
payload := map[string]interface{}{
"using": []string{
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
},
"methodCalls": []interface{}{
[]interface{}{"Email/set", map[string]interface{}{
"accountId": e.accountID,
"create": map[string]interface{}{
"draft": map[string]interface{}{
"from": []map[string]string{
{"name": e.fromName, "email": e.fromEmail},
},
"to": []map[string]string{
{"email": to},
},
"subject": subject,
"mailboxIds": map[string]bool{e.draftsID: true},
"keywords": map[string]bool{"$draft": true},
"bodyValues": map[string]interface{}{
"text": map[string]interface{}{
"charset": "utf-8",
"value": textContent,
},
"html": map[string]interface{}{
"charset": "utf-8",
"value": htmlContent,
},
},
"textBody": []map[string]string{
{"partId": "text", "type": "text/plain"},
},
"htmlBody": []map[string]string{
{"partId": "html", "type": "text/html"},
},
},
},
}, "0"},
[]interface{}{"EmailSubmission/set", map[string]interface{}{
"accountId": e.accountID,
"onSuccessDestroyEmail": []string{"#sendIt"},
"create": map[string]interface{}{
"sendIt": map[string]interface{}{
"emailId": "#draft",
"identityId": e.identityID,
},
},
}, "1"},
},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", fastmailAPIURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+e.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("JMAP API error: %d: %s", resp.StatusCode, string(respBody))
}
// Check for errors in response
var result struct {
MethodResponses [][]interface{} `json:"methodResponses"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
// Check Email/set response for errors
if len(result.MethodResponses) > 0 {
if data, ok := result.MethodResponses[0][1].(map[string]interface{}); ok {
if notCreated, ok := data["notCreated"].(map[string]interface{}); ok {
if draft, ok := notCreated["draft"].(map[string]interface{}); ok {
if desc, ok := draft["description"].(string); ok {
return fmt.Errorf("failed to create email: %s", desc)
}
}
}
}
}
// Check EmailSubmission/set response for errors
if len(result.MethodResponses) > 1 {
if data, ok := result.MethodResponses[1][1].(map[string]interface{}); ok {
if notCreated, ok := data["notCreated"].(map[string]interface{}); ok {
if sendIt, ok := notCreated["sendIt"].(map[string]interface{}); ok {
if desc, ok := sendIt["description"].(string); ok {
return fmt.Errorf("failed to send email: %s", desc)
}
}
}
}
}
return nil
}
@@ -228,6 +228,22 @@ func (us *UserService) FindByUserID(userID string) *userpb.User {
return nil
}
// FindByOAuthIdentity finds a user by their OAuth provider and provider user ID
func (us *UserService) FindByOAuthIdentity(provider, providerUserID string) *userpb.User {
us.mu.RLock()
defer us.mu.RUnlock()
oauthKey := fmt.Sprintf("%s:%s", provider, providerUserID)
if userID, ok := us.database.OauthIndex[oauthKey]; ok {
for _, user := range us.database.Users {
if user.UserId == userID {
return user
}
}
}
return nil
}
// FindByDisplayName finds a user by display name (case-insensitive)
func (us *UserService) FindByDisplayName(displayName string) *userpb.User {
us.mu.RLock()
@@ -450,3 +466,45 @@ func (us *UserService) SetAdmin(userID string, isAdmin bool) error {
}
return fmt.Errorf("user not found")
}
// Delete permanently removes a user from the database
func (us *UserService) Delete(userID string) error {
us.mu.Lock()
defer us.mu.Unlock()
// Find user index
var userIndex = -1
var user *userpb.User
for i, u := range us.database.Users {
if u.UserId == userID {
userIndex = i
user = u
break
}
}
if userIndex == -1 {
return fmt.Errorf("user not found")
}
// Remove from OAuth index
for _, identity := range user.OauthIdentities {
oauthKey := fmt.Sprintf("%s:%s", identity.Provider, identity.ProviderUserId)
delete(us.database.OauthIndex, oauthKey)
}
// Remove from display name index
if user.DisplayNameLower != "" {
delete(us.database.DisplayNameIndex, user.DisplayNameLower)
}
// Remove from users slice
us.database.Users = append(us.database.Users[:userIndex], us.database.Users[userIndex+1:]...)
if err := us.save(); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
log.Printf("[UserService] Deleted user %s (%s)", userID, user.DisplayName)
return nil
}
@@ -0,0 +1,15 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "mac_build_handler_lib",
srcs = ["mac_build_handler.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/build/mac_build_handler",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/aws"],
)
go_binary(
name = "mac_build_handler",
embed = [":mac_build_handler_lib"],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,350 @@
package main
import (
"archive/zip"
"crypto/sha256"
"encoding/xml"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
)
var bucketName = "eagle0-windows" // Using existing bucket with /mac/ prefix
var appcastPath = "mac/appcast.xml"
var buildsRoot = "mac/builds/"
// Sparkle Appcast XML structures
type Appcast struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Xmlns string `xml:"xmlns:sparkle,attr"`
Channel Channel `xml:"channel"`
}
type Channel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
Items []Item `xml:"item"`
}
type Item struct {
Title string `xml:"title"`
PubDate string `xml:"pubDate"`
SparkleVersion string `xml:"sparkle:version"`
SparkleShortVersion string `xml:"sparkle:shortVersionString"`
Description string `xml:"description,omitempty"`
Enclosure Enclosure `xml:"enclosure"`
}
type Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
EdSig string `xml:"sparkle:edSignature,attr"`
}
func zipApp(appPath string, zipPath string) error {
zipFile, err := os.Create(zipPath)
if err != nil {
return fmt.Errorf("failed to create zip file: %w", err)
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
appBase := filepath.Base(appPath)
// Use WalkDir with Lstat to properly handle symlinks
err = filepath.WalkDir(appPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Get the relative path from the app's parent directory
relPath, err := filepath.Rel(filepath.Dir(appPath), path)
if err != nil {
return err
}
// Skip if it's the root
if relPath == appBase && d.IsDir() {
return nil
}
// Use Lstat to get info without following symlinks
info, err := os.Lstat(path)
if err != nil {
return err
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
linkTarget, err := os.Readlink(path)
if err != nil {
return err
}
// Create symlink entry in zip
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = relPath
header.Method = zip.Store
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = writer.Write([]byte(linkTarget))
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
// Use forward slashes and preserve the .app directory structure
header.Name = relPath
if info.IsDir() {
header.Name += "/"
} else {
header.Method = zip.Deflate
}
// Preserve executable permissions
header.SetMode(info.Mode())
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
if !info.IsDir() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
if err != nil {
return err
}
}
return nil
})
return err
}
func signWithSparkle(filePath string, privateKeyPath string) (string, error) {
// Use Sparkle's sign_update tool
// First, try to find it in the Sparkle cache
sparkleSignTool := "/tmp/sparkle-cache/Sparkle-2.6.4/bin/sign_update"
// If not found, download Sparkle
if _, err := os.Stat(sparkleSignTool); os.IsNotExist(err) {
log.Println("Sparkle sign_update not found, downloading...")
cmd := exec.Command("bash", "-c", `
mkdir -p /tmp/sparkle-cache
curl -sL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz | tar -xJ -C /tmp/sparkle-cache
`)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to download Sparkle: %s: %w", output, err)
}
}
// Sign the file
cmd := exec.Command(sparkleSignTool, filePath, "-s", privateKeyPath)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to sign file: %w", err)
}
// sign_update outputs: sparkle:edSignature="<signature>" length="<length>"
// We need to extract just the signature
signature := strings.TrimSpace(string(output))
// Parse out the signature from the output
if strings.Contains(signature, "sparkle:edSignature=\"") {
start := strings.Index(signature, "sparkle:edSignature=\"") + len("sparkle:edSignature=\"")
end := strings.Index(signature[start:], "\"")
if end > 0 {
signature = signature[start : start+end]
}
}
return signature, nil
}
func getFileSize(path string) (int64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func fetchAppcast(bb aws.BucketBasics) (*Appcast, error) {
content, err := bb.FetchString(bucketName, appcastPath)
if err != nil {
// Return empty appcast if doesn't exist
return &Appcast{
Version: "2.0",
Xmlns: "http://www.andymatuschak.org/xml-namespaces/sparkle",
Channel: Channel{
Title: "Eagle0",
Link: "https://assets.eagle0.net/mac/appcast.xml",
Description: "Eagle0 game updates",
Language: "en",
Items: []Item{},
},
}, nil
}
var appcast Appcast
err = xml.Unmarshal([]byte(content), &appcast)
if err != nil {
return nil, fmt.Errorf("failed to parse appcast: %w", err)
}
return &appcast, nil
}
func uploadAppcast(bb aws.BucketBasics, appcast *Appcast) error {
output, err := xml.MarshalIndent(appcast, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal appcast: %w", err)
}
xmlContent := xml.Header + string(output)
return bb.UploadBytesPublic(bucketName, appcastPath, []byte(xmlContent))
}
func main() {
if len(os.Args) < 5 {
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> <sparkle_private_key_path>")
os.Exit(1)
}
appPath := os.Args[1]
version := os.Args[2]
buildNumber := os.Args[3]
privateKeyPath := os.Args[4]
if _, err := os.Stat(appPath); os.IsNotExist(err) {
log.Fatalf("App not found: %s", appPath)
}
bb, err := aws.NewBucketBasics()
if err != nil {
log.Fatal(err)
}
// Create ZIP of the app
zipFileName := fmt.Sprintf("eagle0-%s.zip", version)
zipPath := filepath.Join("/tmp", zipFileName)
log.Printf("Creating ZIP: %s", zipPath)
if err := zipApp(appPath, zipPath); err != nil {
log.Fatalf("Failed to create ZIP: %v", err)
}
// Get file size
fileSize, err := getFileSize(zipPath)
if err != nil {
log.Fatalf("Failed to get file size: %v", err)
}
log.Printf("ZIP size: %d bytes", fileSize)
// Sign the ZIP with Sparkle EdDSA
log.Println("Signing ZIP with Sparkle...")
signature, err := signWithSparkle(zipPath, privateKeyPath)
if err != nil {
log.Fatalf("Failed to sign: %v", err)
}
log.Printf("Signature: %s", signature)
// Upload ZIP to S3
remotePath := buildsRoot + zipFileName
log.Printf("Uploading to S3: %s", remotePath)
if err := bb.UploadFilePublic(bucketName, remotePath, zipPath); err != nil {
log.Fatalf("Failed to upload: %v", err)
}
// Also upload as "latest"
latestPath := buildsRoot + "eagle0-latest.zip"
log.Printf("Copying to: %s", latestPath)
if err := bb.UploadFilePublic(bucketName, latestPath, zipPath); err != nil {
log.Fatalf("Failed to upload latest: %v", err)
}
// Update appcast.xml
log.Println("Updating appcast.xml...")
appcast, err := fetchAppcast(bb)
if err != nil {
log.Fatalf("Failed to fetch appcast: %v", err)
}
// Create new item
downloadURL := fmt.Sprintf("https://assets.eagle0.net/%s%s", buildsRoot, zipFileName)
newItem := Item{
Title: fmt.Sprintf("Version %s", version),
PubDate: time.Now().Format(time.RFC1123Z),
SparkleVersion: buildNumber,
SparkleShortVersion: version,
Description: "",
Enclosure: Enclosure{
URL: downloadURL,
Length: fileSize,
Type: "application/octet-stream",
EdSig: signature,
},
}
// Prepend new item (most recent first)
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
// Keep only last 10 versions
if len(appcast.Channel.Items) > 10 {
appcast.Channel.Items = appcast.Channel.Items[:10]
}
if err := uploadAppcast(bb, appcast); err != nil {
log.Fatalf("Failed to upload appcast: %v", err)
}
// Clean up local ZIP
os.Remove(zipPath)
log.Printf("=== Mac build deployed successfully ===")
log.Printf("Version: %s (build %s)", version, buildNumber)
log.Printf("Download URL: %s", downloadURL)
log.Printf("Appcast URL: https://assets.eagle0.net/%s", appcastPath)
}
+32
View File
@@ -0,0 +1,32 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "warmup_lib",
srcs = ["main.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/warmup",
visibility = ["//visibility:private"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
"//src/main/protobuf/net/eagle0/eagle/common:common_go_proto",
"@org_golang_google_grpc//:grpc",
"@org_golang_google_grpc//credentials/insecure",
"@org_golang_google_grpc//metadata",
],
)
go_binary(
name = "warmup",
embed = [":warmup_lib"],
pure = "on",
visibility = ["//visibility:public"],
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "warmup_linux_amd64",
embed = [":warmup_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
)
+470
View File
@@ -0,0 +1,470 @@
// Package main implements a warmup tool for Eagle server.
//
// This tool warms up the JIT by:
// 1. Entering the lobby to get available leaders
// 2. Creating a new game with 2 players (1 human, 1 AI)
// 3. Starting a bidirectional stream for that game
// 4. Posting an Improve command when available
// 5. Verifying that we get ActionResults including NEW_ROUND_ACTION
//
// Usage:
//
// warmup --address=localhost:40032
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"time"
eagle "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
eaglecommon "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/common"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
var (
address = flag.String("address", "localhost:40032", "Eagle server address (host:port)")
timeout = flag.Duration("timeout", 60*time.Second, "Overall timeout for warmup")
)
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lmicroseconds)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
if err := runWarmup(ctx); err != nil {
log.Fatalf("Warmup failed: %v", err)
}
log.Println("Warmup completed successfully!")
}
func runWarmup(ctx context.Context) error {
log.Printf("Connecting to Eagle server at %s...", *address)
conn, err := grpc.NewClient(*address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer conn.Close()
client := eagle.NewEagleClient(conn)
// Add warmup user header to context
md := metadata.Pairs("x-warmup-user", "warmup-tool")
ctx = metadata.NewOutgoingContext(ctx, md)
// Start the bidirectional stream
log.Println("Starting StreamUpdates...")
stream, err := client.StreamUpdates(ctx)
if err != nil {
return fmt.Errorf("failed to start stream: %w", err)
}
// Step 1: Enter the lobby to get available leaders
log.Println("Step 1: Entering lobby...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_EnterLobbyRequest{
EnterLobbyRequest: &eagle.EnterLobbyRequest{},
},
}); err != nil {
return fmt.Errorf("failed to send lobby request: %w", err)
}
lobbyResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetLobbyResponse() != nil
})
if err != nil {
return fmt.Errorf("failed to get lobby response: %w", err)
}
lobby := lobbyResp.GetLobbyResponse()
newGameOptions := lobby.GetNewGameOptions()
if newGameOptions == nil || len(newGameOptions.GetAvailableLeaders()) == 0 {
return fmt.Errorf("no available leaders in lobby response")
}
leader := newGameOptions.GetAvailableLeaders()[0]
log.Printf(" Found leader: %s", leader.GetNameTextId())
// Step 2: Create a new game
log.Println("Step 2: Creating game...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_CreateGameRequest{
CreateGameRequest: &eagle.CreateGameRequest{
DesiredLeaderTextId: leader.GetNameTextId(),
TotalPlayerCount: 2,
HumanPlayerCount: 1,
},
},
}); err != nil {
return fmt.Errorf("failed to send create game request: %w", err)
}
createResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetCreateGameResponse() != nil
})
if err != nil {
return fmt.Errorf("failed to get create game response: %w", err)
}
gameResp := createResp.GetCreateGameResponse()
if gameResp.GetResult() != eagle.JoinGameResult_SUCCESS_JOIN_GAME_RESULT {
return fmt.Errorf("failed to create game: %v", gameResp.GetResult())
}
gameID := gameResp.GetGameId()
log.Printf(" Created game: %d", gameID)
// Step 3: Subscribe to the game stream
log.Println("Step 3: Subscribing to game...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_StreamGameRequest_{
StreamGameRequest: &eagle.UpdateStreamRequest_StreamGameRequest{
GameId: gameID,
UnfilteredResultCount: 0,
},
},
}); err != nil {
return fmt.Errorf("failed to send stream game request: %w", err)
}
// Wait for initial game state with available commands
// Note: ActionResultResponse arrives BEFORE SubscriptionAck, so we can't wait for ack first
streamingTextCount := 0
gotSubscriptionAck := false
gameUpdateResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
// Track subscription ack but don't require it before ActionResultResponse
if resp.GetSubscriptionAck() != nil {
gotSubscriptionAck = true
log.Println(" Subscription acknowledged")
}
gu := resp.GetGameUpdate()
if gu == nil {
return false
}
// Check what type of update this is
switch {
case gu.GetStreamingTextResponse() != nil:
streamingTextCount++
if streamingTextCount%100 == 0 {
log.Printf(" Received %d streaming text updates so far...", streamingTextCount)
}
case gu.GetShardokActionResultResponse() != nil:
log.Printf(" Got ShardokActionResultResponse")
case gu.GetErrorResponse() != nil:
log.Printf(" Got ErrorResponse: %v", gu.GetErrorResponse())
case gu.GetActionResultResponse() != nil:
log.Printf(" Got ActionResultResponse!")
default:
log.Printf(" Got GameUpdate with no recognized content")
}
ar := gu.GetActionResultResponse()
if ar == nil {
return false
}
if ar.GetAvailableCommands() == nil {
log.Printf(" ActionResultResponse has no AvailableCommands (has %d results)", len(ar.GetActionResultViews()))
return false
}
log.Printf(" Found AvailableCommands with %d provinces", len(ar.GetAvailableCommands().GetCommandsByProvince()))
return true
})
log.Printf(" Total streaming text updates received: %d, got subscription ack: %v", streamingTextCount, gotSubscriptionAck)
if err != nil {
return fmt.Errorf("failed to get initial game state: %w", err)
}
gameUpdate := gameUpdateResp.GetGameUpdate()
actionResult := gameUpdate.GetActionResultResponse()
availableCommands := actionResult.GetAvailableCommands()
// Get the token from available commands
eagleToken := availableCommands.GetToken()
// Get commands by province from map
commandsByProvince := availableCommands.GetCommandsByProvince()
log.Printf(" Got game state with %d provinces having commands", len(commandsByProvince))
// Step 4: Find an Improve command and post it
log.Println("Step 4: Looking for Improve command...")
var improveCmd *eagle.AvailableCommand
var provinceID int32
var actingHeroID int32
for pid, provCmds := range commandsByProvince {
for _, cmd := range provCmds.GetCommands() {
if ic := cmd.GetImproveCommand(); ic != nil {
improveCmd = cmd
provinceID = pid
actingHeroID = ic.GetRecommendedHeroId()
break
}
}
if improveCmd != nil {
break
}
}
if improveCmd == nil {
log.Println(" No Improve command available, looking for any command...")
// Try to find any province with commands
for pid, provCmds := range commandsByProvince {
if len(provCmds.GetCommands()) > 0 {
provinceID = pid
log.Printf(" Found command group for province %d with %d commands", pid, len(provCmds.GetCommands()))
break
}
}
// We'll still try to post an Improve command even if not explicitly available
} else {
log.Printf(" Found Improve command for province %d with recommended hero %d", provinceID, actingHeroID)
}
// Post the Improve command
log.Printf(" Posting Improve command with token=%d province=%d heroId=%d...", eagleToken, provinceID, actingHeroID)
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_PostCommandRequest{
PostCommandRequest: &eagle.PostCommandRequest{
GameId: gameID,
Command: &eagle.UniversalCommand{
SealedValue: &eagle.UniversalCommand_EagleCommand{
EagleCommand: &eagle.EagleCommand{
ProvinceId: provinceID,
EagleToken: eagleToken,
Command: &eagle.SelectedCommand{
SealedValue: &eagle.SelectedCommand_ImproveCommand{
ImproveCommand: &eagle.ImproveSelectedCommand{
ImprovementType: eaglecommon.ImprovementType_ECONOMY,
ActingHeroId: actingHeroID,
LockType: false,
},
},
},
},
},
},
},
},
}); err != nil {
return fmt.Errorf("failed to send post command request: %w", err)
}
// Step 5: Verify we get ActionResults
log.Println("Step 5: Waiting for command response and collecting results...")
// Track results while waiting for PostCommandResponse
// ActionResultResponse arrives as GameUpdate BEFORE PostCommandResponse
foundNewRound := false
resultCount := 0
commandCount := 0
postStatus := eagle.PostCommandResponse_UNKNOWN
_, err = waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
if resp.GetPostCommandResponse() != nil {
log.Printf(" Got PostCommandResponse: %v", resp.GetPostCommandResponse().GetStatus())
postStatus = resp.GetPostCommandResponse().GetStatus()
return true
}
// Process GameUpdates while waiting - they contain our action results!
if gu := resp.GetGameUpdate(); gu != nil {
if ar := gu.GetActionResultResponse(); ar != nil {
for _, arv := range ar.GetActionResultViews() {
resultCount++
if arv.GetType() == eaglecommon.ActionResultType_NEW_ROUND_ACTION {
foundNewRound = true
log.Printf(" Found NEW_ROUND_ACTION (date change)!")
}
}
if ar.GetAvailableCommands() != nil {
commandCount = len(ar.GetAvailableCommands().GetCommandsByProvince())
log.Printf(" Got %d action results, %d provinces with commands available", len(ar.GetActionResultViews()), commandCount)
}
}
} else if resp.GetSubscriptionAck() != nil {
log.Println(" (skipping late SubscriptionAck)")
}
return false
})
if err != nil {
return fmt.Errorf("failed to get post command response: %w", err)
}
if postStatus == eagle.PostCommandResponse_SUCCESS {
log.Println(" Command accepted!")
} else if postStatus == eagle.PostCommandResponse_BAD_TOKEN {
log.Printf(" Command rejected: bad token")
} else {
log.Printf(" Command status: %v (continuing anyway - JIT warming up)", postStatus)
}
// Continue collecting any remaining action results with a timeout
log.Println(" Collecting any remaining action results...")
resultCtx, resultCancel := context.WithTimeout(ctx, 10*time.Second)
defer resultCancel()
for {
select {
case <-resultCtx.Done():
log.Println(" Timeout waiting for more results (this is OK)")
goto done
default:
}
resp, err := receiveWithTimeout(stream, 2*time.Second)
if err != nil {
if err == errTimeout {
log.Println(" No more results received")
goto done
}
return fmt.Errorf("error receiving: %w", err)
}
gu := resp.GetGameUpdate()
if gu == nil {
continue
}
ar := gu.GetActionResultResponse()
if ar == nil {
continue
}
for _, arv := range ar.GetActionResultViews() {
resultCount++
if arv.GetType() == eaglecommon.ActionResultType_NEW_ROUND_ACTION {
foundNewRound = true
log.Printf(" Found NEW_ROUND_ACTION (date change)!")
}
}
if ar.GetAvailableCommands() != nil {
commandCount = len(ar.GetAvailableCommands().GetCommandsByProvince())
log.Printf(" Got %d action results, %d provinces with commands available", len(ar.GetActionResultViews()), commandCount)
}
}
done:
// Verify results
log.Println("Verification:")
log.Printf(" Total action results received: %d", resultCount)
log.Printf(" Found NEW_ROUND_ACTION (date change): %v", foundNewRound)
log.Printf(" New commands available: %d provinces", commandCount)
log.Printf(" Post command status: %v", postStatus)
// Step 6: Drop the game to clean up
log.Println("Step 6: Dropping test game...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_DropGameRequest{
DropGameRequest: &eagle.DropGameRequest{
GameId: gameID,
},
},
}); err != nil {
log.Printf("Warning: failed to send drop game request: %v", err)
} else {
// Wait briefly for drop response
_, err := waitForResponseWithTimeout(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetDropGameResponse() != nil
}, 5*time.Second)
if err != nil {
log.Printf("Warning: failed to get drop game response: %v", err)
} else {
log.Println(" Game dropped successfully")
}
}
// Report success based on what we achieved
if resultCount == 0 {
return fmt.Errorf("no action results received")
}
if commandCount == 0 {
return fmt.Errorf("no new commands received after posting command")
}
return nil
}
// waitForResponse waits for a response matching the predicate
func waitForResponse(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool) (*eagle.UpdateStreamResponse, error) {
return waitForResponseWithTimeout(stream, matches, 30*time.Second)
}
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
remaining := time.Until(deadline)
resp, err := receiveWithTimeout(stream, remaining)
if err != nil {
if err == errTimeout {
return nil, fmt.Errorf("timeout waiting for response")
}
log.Printf(" [DEBUG] Error receiving: %v", err)
return nil, err
}
// Log what type of response we got
switch resp.GetResponseDetails().(type) {
case *eagle.UpdateStreamResponse_LobbyResponse:
log.Printf(" [DEBUG] Got LobbyResponse")
case *eagle.UpdateStreamResponse_CreateGameResponse:
log.Printf(" [DEBUG] Got CreateGameResponse")
case *eagle.UpdateStreamResponse_GameUpdate:
log.Printf(" [DEBUG] Got GameUpdate")
case *eagle.UpdateStreamResponse_SubscriptionAck:
log.Printf(" [DEBUG] Got SubscriptionAck")
case *eagle.UpdateStreamResponse_PostCommandResponse:
log.Printf(" [DEBUG] Got PostCommandResponse")
case *eagle.UpdateStreamResponse_DropGameResponse:
log.Printf(" [DEBUG] Got DropGameResponse")
default:
log.Printf(" [DEBUG] Got unknown response type: %T", resp.GetResponseDetails())
}
if matches(resp) {
return resp, nil
}
}
return nil, fmt.Errorf("timeout waiting for response")
}
var errTimeout = fmt.Errorf("receive timeout")
func receiveWithTimeout(stream eagle.Eagle_StreamUpdatesClient, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
type result struct {
resp *eagle.UpdateStreamResponse
err error
}
ch := make(chan result, 1)
go func() {
resp, err := stream.Recv()
ch <- result{resp, err}
}()
select {
case r := <-ch:
if r.err != nil {
if r.err == io.EOF {
return nil, fmt.Errorf("stream closed by server")
}
return nil, r.err
}
return r.resp, nil
case <-time.After(timeout):
return nil, errTimeout
}
}
@@ -1,4 +1,3 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@grpc//bazel:cc_grpc_library.bzl", "cc_grpc_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
@@ -57,16 +56,6 @@ proto_library(
],
)
swift_proto_library(
name = "hostility_swift_proto",
protos = [":hostility_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/protobuf/net/eagle0/eagle/views:__pkg__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "hostility_scala_proto",
visibility = [
@@ -183,15 +172,6 @@ proto_library(
],
)
swift_proto_library(
name = "victory_condition_swift_proto",
protos = [":victory_condition_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "victory_condition_scala_proto",
visibility = ["//visibility:public"],
@@ -1,4 +1,3 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
@@ -50,26 +49,6 @@ proto_library(
],
)
swift_proto_library(
name = "available_command_swift_proto",
protos = [":available_command_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:attack_decision_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:captured_hero_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:control_weather_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:diplomacy_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:prisoner_management_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:province_orders_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "available_command_scala_proto",
visibility = [
@@ -108,16 +87,6 @@ proto_library(
],
)
swift_proto_library(
name = "command_swift_proto",
protos = [":command_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":available_command_swift_proto",
":selected_command_swift_proto",
],
)
scala_proto_library(
name = "command_scala_proto",
visibility = [
@@ -153,30 +122,6 @@ proto_library(
visibility = ["//visibility:public"],
)
swift_proto_library(
name = "selected_command_swift_proto",
protos = [":selected_command_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:attack_decision_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:captured_hero_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:control_weather_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:diplomacy_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:prisoner_management_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:province_orders_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "selected_command_scala_proto",
visibility = [
@@ -27,6 +27,24 @@ service Admin {
// Toggle admin status for a user
rpc SetUserAdmin(SetUserAdminRequest) returns (SetUserAdminResponse) {}
// Create a new invitation and send email
rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse) {}
// List all invitations with optional filtering
rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse) {}
// Revoke an invitation (prevents it from being used)
rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse) {}
// Resend invitation email
rpc ResendInvitation(ResendInvitationRequest) returns (ResendInvitationResponse) {}
// Delete a user permanently
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {}
// Delete an invitation (only revoked/expired/redeemed invitations can be deleted)
rpc DeleteInvitation(DeleteInvitationRequest) returns (DeleteInvitationResponse) {}
}
// Request to list users
@@ -91,3 +109,93 @@ message SetUserAdminResponse {
bool success = 1;
string error_message = 2;
}
// ============== Invitation Management ==============
// Invitation status for admin view
enum InvitationStatus {
INVITATION_STATUS_UNSPECIFIED = 0;
INVITATION_STATUS_PENDING = 1;
INVITATION_STATUS_REDEEMED = 2;
INVITATION_STATUS_EXPIRED = 3;
INVITATION_STATUS_REVOKED = 4;
}
// Invitation info for admin view
message InvitationInfo {
string invitation_code = 1;
string email = 2;
string created_by_user_id = 3;
string created_by_display_name = 4; // Resolved for display
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp expires_at = 6;
InvitationStatus status = 7;
string redeemed_by_user_id = 8;
string redeemed_by_display_name = 9; // Resolved for display
google.protobuf.Timestamp redeemed_at = 10;
}
// Create invitation request
message CreateInvitationRequest {
string email = 1; // Email to send invitation to
int32 expires_in_days = 2; // Days until expiration (default 30 if 0)
}
message CreateInvitationResponse {
bool success = 1;
string error_message = 2;
InvitationInfo invitation = 3;
}
// List invitations request
message ListInvitationsRequest {
InvitationStatus status_filter = 1; // Optional: filter by status (0 = all)
string email_filter = 2; // Optional: filter by email
int32 offset = 3;
int32 limit = 4; // Default 50, max 100
}
message ListInvitationsResponse {
repeated InvitationInfo invitations = 1;
int32 total_count = 2;
}
// Revoke invitation request
message RevokeInvitationRequest {
string invitation_code = 1;
}
message RevokeInvitationResponse {
bool success = 1;
string error_message = 2;
}
// Resend invitation email request
message ResendInvitationRequest {
string invitation_code = 1;
}
message ResendInvitationResponse {
bool success = 1;
string error_message = 2;
}
// Delete user request
message DeleteUserRequest {
string user_id = 1;
}
message DeleteUserResponse {
bool success = 1;
string error_message = 2;
}
// Delete invitation request (only revoked/expired/redeemed can be deleted)
message DeleteInvitationRequest {
string invitation_code = 1;
}
message DeleteInvitationResponse {
bool success = 1;
string error_message = 2;
}
@@ -48,6 +48,9 @@ message GetOAuthUrlRequest {
// If set, the auth service will redirect here after OAuth instead of
// showing "close this window" message.
string return_url = 2;
// Optional: Invitation code for new account creation.
// Required for new users; existing users are grandfathered and don't need this.
string invitation_code = 3;
}
message GetOAuthUrlResponse {
@@ -78,6 +81,7 @@ enum OAuthStatus {
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
OAUTH_STATUS_INVITATION_REQUIRED = 5; // New user but no valid invitation code provided
}
// User information returned from auth endpoints
@@ -1,18 +1,7 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "appropriate_battalions_swift_proto",
protos = [":appropriate_battalions_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "appropriate_battalions_scala_proto",
visibility = [
@@ -32,16 +21,6 @@ proto_library(
],
)
swift_proto_library(
name = "armed_battalion_swift_proto",
protos = [":armed_battalion_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "armed_battalion_scala_proto",
visibility = [
@@ -63,16 +42,6 @@ proto_library(
],
)
swift_proto_library(
name = "army_stats_swift_proto",
protos = [":army_stats_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "army_stats_scala_proto",
visibility = [
@@ -93,18 +62,6 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/common:hostility_proto"],
)
swift_proto_library(
name = "attack_decision_type_swift_proto",
protos = [":attack_decision_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "attack_decision_type_scala_proto",
visibility = [
@@ -127,16 +84,6 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_with_food_cost_swift_proto",
protos = [":battalion_with_food_cost_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "battalion_with_food_cost_scala_proto",
visibility = [
@@ -158,16 +105,6 @@ proto_library(
],
)
swift_proto_library(
name = "captured_hero_option_swift_proto",
protos = [":captured_hero_option_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "captured_hero_option_scala_proto",
visibility = [
@@ -189,16 +126,6 @@ proto_library(
],
)
swift_proto_library(
name = "control_weather_type_swift_proto",
protos = [":control_weather_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "control_weather_type_scala_proto",
visibility = [
@@ -220,18 +147,6 @@ proto_library(
],
)
swift_proto_library(
name = "diplomacy_option_swift_proto",
protos = [":diplomacy_option_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
],
)
scala_proto_library(
name = "diplomacy_option_scala_proto",
visibility = [
@@ -254,19 +169,6 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_proto"],
)
swift_proto_library(
name = "expanded_combat_unit_swift_proto",
protos = [":expanded_combat_unit_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:hero_view_swift_proto",
],
)
scala_proto_library(
name = "expanded_combat_unit_scala_proto",
visibility = [
@@ -292,20 +194,6 @@ proto_library(
],
)
swift_proto_library(
name = "expanded_unaffiliated_hero_swift_proto",
protos = [":expanded_unaffiliated_hero_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:hero_view_swift_proto",
],
)
scala_proto_library(
name = "expanded_unaffiliated_hero_scala_proto",
visibility = [
@@ -332,16 +220,6 @@ proto_library(
],
)
swift_proto_library(
name = "prisoner_management_type_swift_proto",
protos = [":prisoner_management_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "prisoner_management_type_scala_proto",
visibility = [
@@ -363,18 +241,6 @@ proto_library(
],
)
swift_proto_library(
name = "province_orders_swift_proto",
protos = [":province_orders_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
],
)
scala_proto_library(
name = "province_orders_scala_proto",
visibility = [
@@ -45,6 +45,9 @@ service Eagle {
rpc DeleteGame(DeleteGameRequest) returns (DeleteGameResponse) {}
rpc CheckGameExists(CheckGameExistsRequest) returns (CheckGameExistsResponse) {}
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
// Server lifecycle management (for blue-green deployments)
rpc ReloadGames(ReloadGamesRequest) returns (ReloadGamesResponse) {}
}
message PostCommandRequest {
@@ -605,3 +608,12 @@ message DownloadGameSaveRequest {
message DownloadGameSaveResponse {
bytes chunk = 1; // Chunk of zip file data
}
// Server lifecycle management (for blue-green deployments)
message ReloadGamesRequest {}
message ReloadGamesResponse {
bool success = 1;
string error_message = 2;
int32 games_reloaded = 3; // Number of games that were reloaded from disk
}
@@ -1,4 +1,3 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
@@ -8,19 +7,6 @@ package(default_visibility = [
"//src/test/scala/net/eagle0:__subpackages__",
])
swift_proto_library(
name = "action_result_notification_details_swift_proto",
protos = [":action_result_notification_details_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":profession_swift_proto",
":unaffiliated_hero_quest_swift_proto",
],
)
scala_proto_library(
name = "action_result_notification_details_scala_proto",
deps = [":action_result_notification_details_proto"],
@@ -41,15 +27,6 @@ proto_library(
],
)
swift_proto_library(
name = "action_result_type_swift_proto",
protos = [":action_result_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "action_result_type_scala_proto",
deps = [":action_result_type_proto"],
@@ -65,15 +42,6 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_type_swift_proto",
protos = [":battalion_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "battalion_type_scala_proto",
deps = [":battalion_type_proto"],
@@ -89,15 +57,6 @@ proto_library(
],
)
swift_proto_library(
name = "beast_info_swift_proto",
protos = [":beast_info_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "beast_info_scala_proto",
deps = [":beast_info_proto"],
@@ -113,16 +72,6 @@ proto_library(
],
)
swift_proto_library(
name = "chronicle_entry_swift_proto",
protos = [":chronicle_entry_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":date_swift_proto"],
)
scala_proto_library(
name = "chronicle_entry_scala_proto",
deps = ["chronicle_entry_proto"],
@@ -141,12 +90,18 @@ proto_library(
],
)
swift_proto_library(
name = "combat_unit_swift_proto",
protos = [":combat_unit_proto"],
scala_proto_library(
name = "command_type_scala_proto",
deps = [":command_type_proto"],
)
proto_library(
name = "command_type_proto",
srcs = [
"command_type.proto",
],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
@@ -166,15 +121,6 @@ proto_library(
deps = ["@com_google_protobuf//:wrappers_proto"],
)
swift_proto_library(
name = "date_swift_proto",
protos = [":date_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "date_scala_proto",
deps = [":date_proto"],
@@ -190,19 +136,6 @@ proto_library(
],
)
swift_proto_library(
name = "diplomacy_offer_swift_proto",
protos = [":diplomacy_offer_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":date_swift_proto",
":diplomacy_offer_status_swift_proto",
],
)
scala_proto_library(
name = "diplomacy_offer_scala_proto",
visibility = [
@@ -226,15 +159,6 @@ proto_library(
],
)
swift_proto_library(
name = "diplomacy_offer_status_swift_proto",
protos = [":diplomacy_offer_status_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "diplomacy_offer_status_scala_proto",
deps = [":diplomacy_offer_status_proto"],
@@ -250,15 +174,6 @@ proto_library(
],
)
swift_proto_library(
name = "gender_swift_proto",
protos = [":gender_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "gender_scala_proto",
deps = [":gender_proto"],
@@ -272,18 +187,6 @@ proto_library(
],
)
swift_proto_library(
name = "hero_backstory_version_swift_proto",
protos = [":hero_backstory_version_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":date_swift_proto",
],
)
scala_proto_library(
name = "hero_backstory_version_scala_proto",
deps = [":hero_backstory_version_proto"],
@@ -300,15 +203,6 @@ proto_library(
],
)
swift_proto_library(
name = "improvement_type_swift_proto",
protos = [":improvement_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "improvement_type_scala_proto",
visibility = [
@@ -330,15 +224,6 @@ proto_library(
],
)
swift_proto_library(
name = "profession_swift_proto",
protos = [":profession_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "profession_scala_proto",
deps = [":profession_proto"],
@@ -354,19 +239,6 @@ proto_library(
],
)
swift_proto_library(
name = "province_event_swift_proto",
protos = [":province_event_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":beast_info_swift_proto",
":date_swift_proto",
],
)
scala_proto_library(
name = "province_event_scala_proto",
deps = [":province_event_proto"],
@@ -386,15 +258,6 @@ proto_library(
],
)
swift_proto_library(
name = "province_order_type_swift_proto",
protos = [":province_order_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "province_order_type_scala_proto",
deps = [":province_order_type_proto"],
@@ -410,18 +273,6 @@ proto_library(
],
)
swift_proto_library(
name = "recruitment_info_swift_proto",
protos = [":recruitment_info_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":unaffiliated_hero_quest_swift_proto",
],
)
scala_proto_library(
name = "recruitment_info_scala_proto",
deps = [":recruitment_info_proto"],
@@ -438,15 +289,6 @@ proto_library(
deps = [":unaffiliated_hero_quest_proto"],
)
swift_proto_library(
name = "round_phase_swift_proto",
protos = [":round_phase_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "round_phase_scala_proto",
deps = [":round_phase_proto"],
@@ -462,15 +304,6 @@ proto_library(
],
)
swift_proto_library(
name = "tribute_amount_swift_proto",
protos = [":tribute_amount_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "tribute_amount_scala_proto",
visibility = [
@@ -494,16 +327,6 @@ proto_library(
],
)
swift_proto_library(
name = "unaffiliated_hero_quest_swift_proto",
protos = [":unaffiliated_hero_quest_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":battalion_type_swift_proto"],
)
scala_proto_library(
name = "unaffiliated_hero_quest_scala_proto",
deps = [":unaffiliated_hero_quest_proto"],
@@ -520,16 +343,6 @@ proto_library(
deps = [":battalion_type_proto"],
)
swift_proto_library(
name = "unaffiliated_hero_type_swift_proto",
protos = [":unaffiliated_hero_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "unaffiliated_hero_type_scala_proto",
deps = [":unaffiliated_hero_type_proto"],
@@ -555,6 +368,7 @@ go_proto_library(
":beast_info_proto",
":chronicle_entry_proto",
":combat_unit_proto",
":command_type_proto",
":date_proto",
":diplomacy_offer_proto",
":diplomacy_offer_status_proto",
@@ -0,0 +1,52 @@
syntax = "proto3";
package net.eagle0.eagle.common.command_type;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.common.command_type";
// Enum representing the type of a command, without any command-specific data.
// Used to track which command type was last executed on a province.
enum CommandType {
COMMAND_TYPE_UNKNOWN = 0;
COMMAND_TYPE_ALMS = 1;
COMMAND_TYPE_APPREHEND_OUTLAW = 2;
COMMAND_TYPE_ARM_TROOPS = 3;
COMMAND_TYPE_ATTACK_DECISION = 4;
COMMAND_TYPE_CONTROL_WEATHER = 5;
COMMAND_TYPE_DECLINE_QUEST = 6;
COMMAND_TYPE_DEFEND = 7;
COMMAND_TYPE_DIPLOMACY = 8;
COMMAND_TYPE_DIVINE = 9;
COMMAND_TYPE_EXILE_VASSAL = 10;
COMMAND_TYPE_FEAST = 11;
COMMAND_TYPE_FREE_FOR_ALL_DECISION = 12;
COMMAND_TYPE_HANDLE_CAPTURED_HERO = 13;
COMMAND_TYPE_HANDLE_RIOT_CRACK_DOWN = 14;
COMMAND_TYPE_HANDLE_RIOT_DO_NOTHING = 15;
COMMAND_TYPE_HANDLE_RIOT_GIVE = 16;
COMMAND_TYPE_HERO_GIFT = 17;
COMMAND_TYPE_IMPROVE = 18;
COMMAND_TYPE_ISSUE_ORDERS = 19;
COMMAND_TYPE_MANAGE_PRISONERS = 20;
COMMAND_TYPE_MARCH = 21;
COMMAND_TYPE_ORGANIZE_TROOPS = 22;
COMMAND_TYPE_PLEASE_RECRUIT_ME = 23;
COMMAND_TYPE_RECON = 24;
COMMAND_TYPE_RECRUIT_HEROES = 25;
COMMAND_TYPE_RESOLVE_ALLIANCE_OFFER = 26;
COMMAND_TYPE_RESOLVE_BREAK_ALLIANCE = 27;
COMMAND_TYPE_RESOLVE_INVITATION = 28;
COMMAND_TYPE_RESOLVE_RANSOM_OFFER = 29;
COMMAND_TYPE_RESOLVE_TRUCE_OFFER = 30;
COMMAND_TYPE_RESOLVE_TRIBUTE = 31;
COMMAND_TYPE_REST = 32;
COMMAND_TYPE_RETURN = 33;
COMMAND_TYPE_SEND_SUPPLIES = 34;
COMMAND_TYPE_START_EPIDEMIC = 35;
COMMAND_TYPE_SUPPRESS_BEASTS = 36;
COMMAND_TYPE_SWEAR_BROTHERHOOD = 37;
COMMAND_TYPE_TRADE = 38;
COMMAND_TYPE_TRAIN = 39;
COMMAND_TYPE_TRAVEL = 40;
}
@@ -1,4 +1,3 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
@@ -8,31 +7,6 @@ package(default_visibility = [
"//src/test/scala/net/eagle0:__subpackages__",
])
swift_proto_library(
name = "action_result_swift_proto",
protos = [":action_result_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":battalion_swift_proto",
":changed_faction_swift_proto",
":changed_hero_swift_proto",
":changed_province_swift_proto",
":faction_swift_proto",
":hero_swift_proto",
":llm_request_swift_proto",
":llm_response_swift_proto",
":province_swift_proto",
":shardok_battle_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
],
)
scala_proto_library(
name = "action_result_scala_proto",
deps = [":action_result_proto"],
@@ -58,24 +32,13 @@ proto_library(
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_proto",
"//src/main/protobuf/net/eagle0/eagle/common:command_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_proto",
"@com_google_protobuf//:wrappers_proto",
],
)
swift_proto_library(
name = "army_swift_proto",
protos = [":army_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":supplies_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "army_scala_proto",
deps = [":army_proto"],
@@ -94,15 +57,6 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_swift_proto",
protos = [":battalion_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
],
)
scala_proto_library(
name = "battalion_scala_proto",
deps = [":battalion_proto"],
@@ -116,12 +70,6 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto"],
)
swift_proto_library(
name = "battle_revelation_swift_proto",
protos = [":battle_revelation_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
)
scala_proto_library(
name = "battle_revelation_scala_proto",
deps = [":battle_revelation_proto"],
@@ -134,20 +82,6 @@ proto_library(
],
)
swift_proto_library(
name = "changed_faction_swift_proto",
protos = [":changed_faction_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":changed_hero_swift_proto",
":changed_province_swift_proto",
":faction_relationship_swift_proto",
":faction_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_swift_proto",
],
)
scala_proto_library(
name = "changed_faction_scala_proto",
deps = [
@@ -169,16 +103,6 @@ proto_library(
],
)
swift_proto_library(
name = "changed_hero_swift_proto",
protos = [":changed_hero_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":event_for_hero_backstory_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
],
)
scala_proto_library(
name = "changed_hero_scala_proto",
deps = ["changed_hero_proto"],
@@ -196,23 +120,6 @@ proto_library(
],
)
swift_proto_library(
name = "changed_province_swift_proto",
protos = [":changed_province_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":deferred_change_swift_proto",
":province_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:army_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battle_revelation_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:supplies_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
],
)
scala_proto_library(
name = "changed_province_scala_proto",
deps = ["changed_province_proto"],
@@ -237,15 +144,6 @@ proto_library(
],
)
swift_proto_library(
name = "client_text_swift_proto",
protos = [":client_text_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_swift_proto",
],
)
scala_proto_library(
name = "client_text_scala_proto",
deps = ["client_text_proto"],
@@ -259,12 +157,6 @@ proto_library(
],
)
swift_proto_library(
name = "deferred_change_swift_proto",
protos = [":deferred_change_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
)
scala_proto_library(
name = "deferred_change_scala_proto",
deps = [":deferred_change_proto"],
@@ -275,15 +167,6 @@ proto_library(
srcs = ["deferred_change.proto"],
)
swift_proto_library(
name = "event_for_chronicle_swift_proto",
protos = [":event_for_chronicle_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "event_for_chronicle_scala_proto",
deps = [":event_for_chronicle_proto"],
@@ -298,19 +181,6 @@ proto_library(
],
)
swift_proto_library(
name = "event_for_hero_backstory_swift_proto",
protos = [":event_for_hero_backstory_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
],
)
scala_proto_library(
name = "event_for_hero_backstory_scala_proto",
deps = [":event_for_hero_backstory_proto"],
@@ -328,19 +198,6 @@ proto_library(
],
)
swift_proto_library(
name = "faction_swift_proto",
protos = [":faction_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":faction_relationship_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_swift_proto",
],
)
scala_proto_library(
name = "faction_scala_proto",
deps = [":faction_proto"],
@@ -359,15 +216,6 @@ proto_library(
],
)
swift_proto_library(
name = "faction_relationship_swift_proto",
protos = [":faction_relationship_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "faction_relationship_scala_proto",
deps = [":faction_relationship_proto"],
@@ -384,17 +232,6 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/eagle/common:date_proto"],
)
swift_proto_library(
name = "game_parameters_swift_proto",
protos = [":game_parameters_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_swift_proto",
],
)
scala_proto_library(
name = "game_parameters_scala_proto",
visibility = ["//visibility:public"],
@@ -413,18 +250,6 @@ proto_library(
],
)
swift_proto_library(
name = "game_swift_proto",
protos = [":game_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":action_result_swift_proto",
":game_state_swift_proto",
":shardok_results_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "game_scala_proto",
deps = [":game_proto"],
@@ -442,27 +267,6 @@ proto_library(
],
)
swift_proto_library(
name = "game_state_swift_proto",
protos = [":game_state_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":battalion_swift_proto",
":faction_swift_proto",
":hero_swift_proto",
":llm_request_swift_proto",
":llm_response_swift_proto",
":province_swift_proto",
":run_status_swift_proto",
":shardok_battle_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
],
)
scala_proto_library(
name = "game_state_scala_proto",
deps = [":game_state_proto"],
@@ -489,18 +293,6 @@ proto_library(
],
)
swift_proto_library(
name = "hero_swift_proto",
protos = [":hero_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":event_for_hero_backstory_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:gender_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:hero_backstory_version_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
],
)
scala_proto_library(
name = "hero_scala_proto",
deps = [":hero_proto"],
@@ -518,13 +310,6 @@ proto_library(
],
)
swift_proto_library(
name = "llm_response_swift_proto",
protos = [":llm_response_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [":llm_request_swift_proto"],
)
scala_proto_library(
name = "llm_response_scala_proto",
deps = [":llm_response_proto"],
@@ -540,21 +325,6 @@ proto_library(
],
)
swift_proto_library(
name = "llm_request_swift_proto",
protos = [":llm_request_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":event_for_chronicle_swift_proto",
":event_for_hero_backstory_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
],
)
scala_proto_library(
name = "llm_request_scala_proto",
deps = [":llm_request_proto"],
@@ -578,27 +348,6 @@ proto_library(
],
)
swift_proto_library(
name = "province_swift_proto",
protos = [":province_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":army_swift_proto",
":battle_revelation_swift_proto",
":deferred_change_swift_proto",
":supplies_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
],
)
scala_proto_library(
name = "province_scala_proto",
visibility = ["//visibility:public"],
@@ -616,8 +365,8 @@ proto_library(
":deferred_change_proto",
":supplies_proto",
":unaffiliated_hero_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:command_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_proto",
@@ -626,13 +375,6 @@ proto_library(
],
)
swift_proto_library(
name = "run_status_swift_proto",
protos = [":run_status_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [],
)
scala_proto_library(
name = "run_status_scala_proto",
deps = [":run_status_proto"],
@@ -645,13 +387,6 @@ proto_library(
],
)
swift_proto_library(
name = "running_games_swift_proto",
protos = [":running_games_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [],
)
scala_proto_library(
name = "running_games_scala_proto",
deps = [":running_games_proto"],
@@ -662,16 +397,6 @@ proto_library(
srcs = ["running_games.proto"],
)
swift_proto_library(
name = "shardok_battle_swift_proto",
protos = [":shardok_battle_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":army_swift_proto",
"//src/main/protobuf/net/eagle0/common:victory_condition_swift_proto",
],
)
scala_proto_library(
name = "shardok_battle_scala_proto",
deps = [":shardok_battle_proto"],
@@ -688,17 +413,6 @@ proto_library(
],
)
swift_proto_library(
name = "shardok_results_swift_proto",
protos = [":shardok_results_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_swift_proto",
],
)
scala_proto_library(
name = "shardok_results_scala_proto",
deps = [":shardok_results_proto"],
@@ -716,14 +430,6 @@ proto_library(
],
)
swift_proto_library(
name = "supplies_swift_proto",
protos = [":supplies_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
],
)
scala_proto_library(
name = "supplies_scala_proto",
deps = [":supplies_proto"],
@@ -737,20 +443,6 @@ proto_library(
deps = ["@com_google_protobuf//:wrappers_proto"],
)
swift_proto_library(
name = "unaffiliated_hero_swift_proto",
protos = [":unaffiliated_hero_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_swift_proto",
],
)
scala_proto_library(
name = "unaffiliated_hero_scala_proto",
deps = [":unaffiliated_hero_proto"],
@@ -12,6 +12,7 @@ import "src/main/protobuf/net/eagle0/eagle/common/action_result_notification_det
import "src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/battalion_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/chronicle_entry.proto";
import "src/main/protobuf/net/eagle0/eagle/common/command_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/date.proto";
import "src/main/protobuf/net/eagle0/eagle/common/round_phase.proto";
import "src/main/protobuf/net/eagle0/eagle/internal/battalion.proto";
@@ -63,7 +64,9 @@ message ActionResult {
.google.protobuf.Int32Value province_acted = 17;
.net.eagle0.eagle.api.SelectedCommand last_command_type_for_acting_province = 28;
// Deprecated: Use last_command_type_for_acting_province instead. Kept for backwards compatibility with saved games.
.net.eagle0.eagle.api.SelectedCommand deprecated_last_command_type_for_acting_province = 28;
.net.eagle0.eagle.common.command_type.CommandType last_command_type_for_acting_province = 43;
.google.protobuf.Int32Value leader = 18;
@@ -7,8 +7,8 @@ syntax = "proto3";
package net.eagle0.eagle.internal;
import "google/protobuf/wrappers.proto";
import "src/main/protobuf/net/eagle0/eagle/api/selected_command.proto";
import "src/main/protobuf/net/eagle0/eagle/common/battalion_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/command_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/date.proto";
import "src/main/protobuf/net/eagle0/eagle/common/improvement_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/province_event.proto";
@@ -104,7 +104,7 @@ message Province {
.net.eagle0.eagle.common.Date last_beasts_date = 25;
.net.eagle0.eagle.common.Date last_riot_date = 35;
.net.eagle0.eagle.api.SelectedCommand last_command = 26;
.net.eagle0.eagle.common.command_type.CommandType last_command = 26;
int32 castle_count = 33;
int32 hero_cap = 39;
@@ -64,3 +64,31 @@ message RefreshToken {
message RefreshTokenDatabase {
repeated RefreshToken tokens = 1;
}
// Invitation status
enum InvitationStatus {
INVITATION_STATUS_UNSPECIFIED = 0;
INVITATION_STATUS_PENDING = 1; // Invitation sent, not yet used
INVITATION_STATUS_REDEEMED = 2; // Invitation used to create account
INVITATION_STATUS_EXPIRED = 3; // Invitation past expiration date
INVITATION_STATUS_REVOKED = 4; // Admin cancelled invitation
}
// Invitation record for restricting new account creation
message Invitation {
string invitation_code = 1; // Secure random code (base64url, 32+ chars)
string email = 2; // Email address invitation was sent to
string created_by_user_id = 3; // Admin who created this invitation
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp expires_at = 5;
InvitationStatus status = 6;
string redeemed_by_user_id = 7; // User ID that used this invitation
google.protobuf.Timestamp redeemed_at = 8;
}
// Container for all invitations
message InvitationDatabase {
repeated Invitation invitations = 1;
// Code -> index in invitations array for fast lookup
map<string, int32> code_index = 2;
}
@@ -1,18 +1,7 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "action_result_view_swift_proto",
protos = [":action_result_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_swift_proto",
],
)
scala_proto_library(
name = "action_result_view_scala_proto",
visibility = [
@@ -36,15 +25,6 @@ proto_library(
],
)
swift_proto_library(
name = "army_view_swift_proto",
protos = [":army_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
],
)
scala_proto_library(
name = "army_view_scala_proto",
visibility = [
@@ -65,19 +45,6 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_view_swift_proto",
protos = [":battalion_view_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/protobuf/net/eagle0/eagle/internal:__pkg__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
],
)
scala_proto_library(
name = "battalion_view_scala_proto",
visibility = [
@@ -102,15 +69,6 @@ proto_library(
],
)
swift_proto_library(
name = "faction_relationship_view_swift_proto",
protos = [":faction_relationship_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "faction_relationship_view_scala_proto",
visibility = [
@@ -131,16 +89,6 @@ proto_library(
],
)
swift_proto_library(
name = "faction_view_swift_proto",
protos = [":faction_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":faction_relationship_view_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
],
)
scala_proto_library(
name = "faction_view_scala_proto",
visibility = [
@@ -163,23 +111,6 @@ proto_library(
],
)
swift_proto_library(
name = "game_state_view_swift_proto",
protos = [":game_state_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":battalion_view_swift_proto",
":faction_view_swift_proto",
":hero_view_swift_proto",
":province_view_swift_proto",
":shardok_battle_view_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
],
)
scala_proto_library(
name = "game_state_view_scala_proto",
visibility = [
@@ -208,20 +139,6 @@ proto_library(
],
)
swift_proto_library(
name = "hero_view_swift_proto",
protos = [":hero_view_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":stat_with_condition_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:gender_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
],
)
scala_proto_library(
name = "hero_view_scala_proto",
visibility = [
@@ -247,13 +164,6 @@ proto_library(
],
)
swift_proto_library(
name = "incoming_army_view_swift_proto",
protos = [":incoming_army_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [":battalion_view_swift_proto"],
)
scala_proto_library(
name = "incoming_army_view_scala_proto",
visibility = [
@@ -270,27 +180,6 @@ proto_library(
deps = [":battalion_view_proto"],
)
swift_proto_library(
name = "province_view_swift_proto",
protos = [":province_view_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":army_view_swift_proto",
":battalion_view_swift_proto",
":incoming_army_view_swift_proto",
":stat_with_condition_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
],
)
scala_proto_library(
name = "province_view_scala_proto",
visibility = [
@@ -323,13 +212,6 @@ proto_library(
],
)
swift_proto_library(
name = "stat_with_condition_swift_proto",
protos = [":stat_with_condition_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [],
)
scala_proto_library(
name = "stat_with_condition_scala_proto",
visibility = [
@@ -349,15 +231,6 @@ proto_library(
],
)
swift_proto_library(
name = "shardok_battle_view_swift_proto",
protos = [":shardok_battle_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/common:hostility_swift_proto",
],
)
scala_proto_library(
name = "shardok_battle_view_scala_proto",
visibility = [
@@ -1,23 +1,8 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "action_result_view_swift_proto",
protos = [":action_result_view_proto"],
visibility = ["//visibility:public"],
deps = [
":game_state_view_swift_proto",
":odds_view_swift_proto",
":unit_view_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:action_type_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:game_status_swift_proto",
],
)
scala_proto_library(
name = "action_result_view_scala_proto",
visibility = ["//visibility:public"],
@@ -42,18 +27,6 @@ proto_library(
],
)
swift_proto_library(
name = "command_descriptor_swift_proto",
protos = [":command_descriptor_proto"],
visibility = ["//visibility:public"],
deps = [
":odds_view_swift_proto",
":roll_request_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
],
)
scala_proto_library(
name = "command_descriptor_scala_proto",
visibility = ["//visibility:public"],
@@ -82,20 +55,6 @@ proto_library(
],
)
swift_proto_library(
name = "game_state_view_swift_proto",
protos = [":game_state_view_proto"],
visibility = ["//visibility:public"],
deps = [
":unit_view_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:game_status_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:hex_map_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:player_info_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:tile_modifier_with_coords_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:weather_swift_proto",
],
)
cc_proto_library(
name = "game_state_view_cc_proto",
visibility = ["//visibility:public"],
@@ -119,13 +78,6 @@ proto_library(
],
)
swift_proto_library(
name = "odds_view_swift_proto",
protos = [":odds_view_proto"],
visibility = ["//visibility:public"],
deps = [":unit_view_swift_proto"],
)
cc_proto_library(
name = "odds_view_cc_proto",
visibility = ["//visibility:public"],
@@ -153,16 +105,6 @@ proto_library(
],
)
swift_proto_library(
name = "roll_request_swift_proto",
protos = [":roll_request_proto"],
visibility = ["//visibility:public"],
deps = [
":odds_view_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_swift_proto",
],
)
cc_proto_library(
name = "roll_request_cc_proto",
visibility = ["//visibility:public"],
@@ -179,17 +121,6 @@ proto_library(
],
)
swift_proto_library(
name = "unit_view_swift_proto",
protos = [":unit_view_proto"],
visibility = ["//visibility:public"],
deps = [
"//src/main/protobuf/net/eagle0/shardok/common:command_type_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:hostility_swift_proto",
],
)
cc_proto_library(
name = "unit_view_cc_proto",
visibility = ["//visibility:public"],
@@ -1,19 +1,8 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "action_type_swift_proto",
protos = [":action_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "action_type_cc_proto",
visibility = ["//visibility:public"],
@@ -26,16 +15,6 @@ proto_library(
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
)
swift_proto_library(
name = "command_type_swift_proto",
protos = [":command_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "command_type_cc_proto",
visibility = ["//visibility:public"],
@@ -48,16 +27,6 @@ proto_library(
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
)
swift_proto_library(
name = "coords_swift_proto",
protos = [":coords_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "coords_cc_proto",
visibility = ["//visibility:public"],
@@ -70,16 +39,6 @@ proto_library(
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
)
swift_proto_library(
name = "game_status_swift_proto",
protos = [":game_status_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":victory_condition_swift_proto"],
)
cc_proto_library(
name = "game_status_cc_proto",
visibility = ["//visibility:public"],
@@ -93,21 +52,6 @@ proto_library(
deps = [":victory_condition_proto"],
)
swift_proto_library(
name = "hex_map_swift_proto",
protos = [":hex_map_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":coords_swift_proto",
":hex_map_direction_swift_proto",
":terrain_swift_proto",
":tile_modifier_swift_proto",
],
)
scala_proto_library(
name = "hex_map_scala_proto",
visibility = ["//visibility:public"],
@@ -134,16 +78,6 @@ proto_library(
],
)
swift_proto_library(
name = "hex_map_direction_swift_proto",
protos = [":hex_map_direction_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "hex_map_direction_cc_proto",
visibility = ["//visibility:public"],
@@ -156,16 +90,6 @@ proto_library(
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
)
swift_proto_library(
name = "hostility_swift_proto",
protos = [":hostility_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "hostility_cc_proto",
visibility = ["//visibility:public"],
@@ -178,16 +102,6 @@ proto_library(
visibility = ["//visibility:public"],
)
swift_proto_library(
name = "player_info_swift_proto",
protos = [":player_info_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":victory_condition_swift_proto"],
)
cc_proto_library(
name = "player_info_cc_proto",
visibility = ["//visibility:public"],
@@ -205,16 +119,6 @@ proto_library(
],
)
swift_proto_library(
name = "terrain_swift_proto",
protos = [":terrain_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":tile_modifier_swift_proto"],
)
cc_proto_library(
name = "terrain_cc_proto",
visibility = ["//visibility:public"],
@@ -234,16 +138,6 @@ proto_library(
deps = [":tile_modifier_proto"],
)
swift_proto_library(
name = "tile_modifier_swift_proto",
protos = [":tile_modifier_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "tile_modifier_cc_proto",
visibility = ["//visibility:public"],
@@ -265,19 +159,6 @@ proto_library(
],
)
swift_proto_library(
name = "tile_modifier_with_coords_swift_proto",
protos = [":tile_modifier_with_coords_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":coords_swift_proto",
":tile_modifier_swift_proto",
],
)
cc_proto_library(
name = "tile_modifier_with_coords_cc_proto",
visibility = ["//visibility:public"],
@@ -294,16 +175,6 @@ proto_library(
],
)
swift_proto_library(
name = "victory_condition_swift_proto",
protos = [":victory_condition_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
cc_proto_library(
name = "victory_condition_cc_proto",
visibility = ["//visibility:public"],
@@ -316,16 +187,6 @@ proto_library(
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
)
swift_proto_library(
name = "weather_swift_proto",
protos = [":weather_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":hex_map_direction_swift_proto"],
)
cc_proto_library(
name = "weather_cc_proto",
visibility = ["//visibility:public"],
@@ -1,26 +1,8 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "action_result_swift_proto",
protos = [":action_result_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/internal:__pkg__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":odds_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:action_type_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:game_status_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:tile_modifier_with_coords_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/common:weather_swift_proto",
],
)
scala_proto_library(
name = "action_result_scala_proto",
visibility = ["//visibility:public"],
@@ -67,12 +49,6 @@ proto_library(
],
)
swift_proto_library(
name = "odds_swift_proto",
protos = [":odds_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
)
cc_proto_library(
name = "odds_cc_proto",
visibility = ["//visibility:public"],

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