Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 6882fabc95 Simplify eagle-exec by using active instance file
Instead of querying nginx config on every invocation, deploy-blue-green.sh
now writes the active instance to /opt/eagle0/.active-instance.

eagle-exec reads this file directly, with a fallback to checking running
containers if the file doesn't exist.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:33:02 -08:00
84c1bf61b0 Add eagle-exec helper for blue-green deployments (#5567)
Adds a helper script that automatically runs docker exec against the
active Eagle instance (blue or green), determined by checking nginx
config.

Usage:
  eagle-exec printenv GEMINI_API_KEY
  eagle-exec jcmd 1 VM.flags
  eagle-exec sh

The script is deployed to /opt/eagle0/scripts/ and symlinked to
/usr/local/bin/eagle-exec for easy access.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:31:53 -08:00
21dfa12197 Fix Gemini 404 errors by adding missing model name case (#5566)
When LlmProvider was set to "gemini", the validation code was missing a
case for extracting the GeminiModelName, causing it to default to an
empty string. This resulted in malformed API URLs like:
https://generativelanguage.googleapis.com/v1beta/models/:streamGenerateContent

which returned 404 errors.

Added the "gemini" case to properly extract GeminiModelName from
settings.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:12:33 -08:00
de4c568aa0 Handle LLM validation errors gracefully (#5565)
When switching LLM providers in the admin console, if the API key for
the target provider isn't configured, the validation would crash with
ExceptionInInitializerError. This fix:

- Wraps service creation in Try to catch initialization errors
- Logs the error with context (likely missing API key)
- Reports to Sentry for monitoring
- Returns a user-friendly error message
- Falls back to keeping the old setting unchanged

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:53:05 -08:00
edccde9a90 Fix Gemini provider validation in admin settings (#5564)
The LLM settings validation in GameAdminServiceImpl was missing the
"gemini" provider case, causing "Unknown provider: gemini" errors when
trying to switch to Gemini from the admin console.

Changes:
- Add GeminiServiceImpl import
- Add "gemini" case to validateLlmSettings()
- Add "GeminiModelName" to llmSettingKeys set
- Add gemini_service_impl dep to BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:09:50 -08:00
0f7f019f14 Replace characters that could cause IP/publicity issues (#5563)
Replace 13 heroes that referenced real people or copyrighted characters
with original alternatives that preserve gameplay stats and general archetypes:

Real people replaced:
- Jesse "The Body" Ventura → Magnus "The Boulder" Varnok
- Jeb! → Aldric the Overlooked
- Ringo → Tomlin the Tuneful
- Flea → Zander the Restless

Copyrighted characters replaced:
- Darth Plagueis → Voros the Undying
- Deckard Cain → Old Marek the Learned
- Skeletor → Karvax the Fleshless
- Trap Jaw → Ironmaw
- Shu Lien → Mei Shen
- Roger the Shrubber → Hedrick the Hedge-Merchant
- Kriss Kross → The Tumbler
- Daddy Mac → Silvio the Smooth
- Ul (removed Warcraft "Far Seer" reference)

Image files in eagle0-headshots bucket have been renamed to match.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:45:11 -08:00
adcafe3224 Refactor LLM update interface to use Scala domain types instead of protos (#5562)
* Refactor LLM update interface to use Scala domain types instead of protos

Replace proto types (GeneratedTextRequest, LLMResponse) with Scala domain
type (GeneratedTextRequestT) throughout the LLM streaming interface. This
eliminates unnecessary proto conversions and simplifies the code.

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

* Remove unused llm_response_scala_proto dependency from llm_resolver

The LLMResponse proto is no longer used since we now pass Scala domain
types through the LLM update interface.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:38:34 -08:00
89ad8371b5 Add Gemini LLM support with model comparison documentation (#5561)
This adds Google Gemini as a third LLM provider option alongside OpenAI
and Anthropic (Claude). Based on streaming latency tests, Gemini 2.5
Flash-Lite shows the fastest time-to-first-token (~0.6s) at the lowest
cost ($0.10/$0.40 per 1M tokens).

Changes:
- Add GeminiServiceImpl.scala following OpenAI/Claude pattern
- Add GeminiModelName setting (default: gemini-2.5-flash-lite)
- Update LlmResolver to support "gemini" provider
- Update admin console dropdowns with Gemini options
- Reorder dropdown options to show recommended models first:
  - OpenAI: gpt-4.1-mini (was gpt-5-mini)
  - Claude: claude-3-5-haiku (already first)
  - Gemini: gemini-2.5-flash-lite
- Add GEMINI_API_KEY to docker-compose and deployment workflow
- Add docs/LLM_MODEL_COMPARISON.md with latency/pricing comparison

Streaming TTFT results from testing:
- Gemini 2.5 Flash-Lite: ~0.6s (fastest, cheapest)
- gpt-4.1-mini: ~1.7s
- claude-3-5-haiku: ~1.9s
- Gemini 2.5 Flash: ~6.8s (surprisingly slow)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:23:38 -08:00
b8c584572d Filter LLM requests for deleted games before sending (#5560)
* Filter LLM requests for deleted games before sending

Add a game validity check to LlmResolver to prevent sending LLM requests
for games that have been deleted (e.g., warmup games during blue-green
deployments). This avoids wasted API calls and eliminates the "Ignoring
LLM response for deleted game" log messages at startup.

Changes:
- Add LlmResolverGameDeleted result type to LlmResolver
- Add isGameValid callback parameter to LlmResolver constructor
- Check game validity before sending each request
- Handle LlmResolverGameDeleted in UnrequestedTextHandler
- Pass gameControllerInfos.contains check from GamesManager

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

* Move proto conversion inside TextGenerationSuccess case

The proto conversion is only needed for the success case where we
actually send the request. Moving it avoids unnecessary conversion
for dependency-blocked requests.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:10:11 -08:00
acb411c4d2 Reorder Claude models to put haiku first (#5559)
Move claude-3-5-haiku to the top of the dropdown since it's the
fastest model for streaming responses (~1.9s TTFT vs ~5.0s for sonnet).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:00:31 -08:00
25f92c7832 Add ANTHROPIC_API_KEY to deployment pipeline (#5558)
* Add ANTHROPIC_API_KEY to docker-compose environment

Required for Claude LLM provider to work in production.

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

* Add ANTHROPIC_API_KEY to deployment workflow

Pass the secret to the deploy job and export it for docker-compose.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 07:25:04 -08:00
0f0d2433bb Fix reasoning_effort for non-reasoning models, add gpt-5.1 option (#5557)
* Only send reasoning_effort for reasoning models (o1, o3)

The reasoning_effort parameter is only valid for OpenAI reasoning models.
Sending it to non-reasoning models like gpt-5-mini causes a 400 Bad Request.

Now only includes reasoning_effort when the model name starts with "o1" or "o3".

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

* Treat models without -mini/-nano as reasoning models, add gpt-5.1 option

- Change reasoning model detection: models with -mini or -nano suffix are
  non-reasoning; all others (gpt-5.1, gpt-5.2, o1, o3, etc.) get reasoning_effort
- Add gpt-5.1 to admin console dropdown options

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:00:53 -08:00
eea61f1c6a Fix create game defaults: 7 total players, 1 human (#5556)
The previous change incorrectly set both total players and human players
to 1. This restores the intended behavior: 7 total players (1 human + 6 AI)
with 1 human player selected by default.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:59:29 -08:00
559f051efa Fix NPE when OkHttp SSE onFailure receives null Throwable (#5555)
OkHttp can call onFailure with a null Throwable when there's an HTTP
error response but no actual exception. Handle this case by:
- Null-checking before calling getMessage()
- Creating a synthetic RuntimeException when t is null

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:49:25 -08:00
a2e136af3d Eagerly initialize S3 client at startup to reduce first-connection latency (#5552)
JFR profiling revealed that the first client connection after deploy was
experiencing 5+ second delays due to lazy initialization of the AWS S3 SDK
(profile loading, TLS handshake to DigitalOcean Spaces, etc.).

This change adds a warmup() method to S3Utils that:
- Forces initialization of the lazy transferManager
- Makes a lightweight headBucket call to complete TLS handshake

The warmup is called early in Main.scala before the server starts accepting
connections, moving the initialization cost to startup time rather than
first-request time.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:43:03 -08:00
c4f887255b Remove --gpt-model-name from docker-compose (#5554)
The --gpt-model-name CLI argument was removed in the LLM model switching
PR (97b313f6), but docker-compose.prod.yml still passed it, causing the
server to fail with "Unknown option --gpt-model-name".

LLM provider and model are now configured via settings (admin console)
instead of CLI arguments.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:41:44 -08:00
97b313f6d5 Add LLM model switching from admin console (#5551)
* Add LLM model switching from admin console

Implement the ability to switch LLM provider and model dynamically from the
admin console with validation before applying changes.

Changes:
- Add String setting type support (StringSetting.scala, generator updates)
- Add LlmProvider, OpenAiModelName, ClaudeModelName settings
- Modify LlmResolver to read provider/model from settings dynamically
- Add recreateLlmCallers() to allow hot-swapping LLM configuration
- Add validation in GameAdminServiceImpl that tests API before applying
- Remove gptModelName command line flag (now configured via settings)
- Update AddSettingsResponse proto with success/errorMessage fields

The admin console now shows these settings and validates them by making
a test API call before applying. If validation fails, the error is returned
and settings remain unchanged.

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

* Add dropdown UI for LLM settings in admin console

Instead of showing LLM provider and model names as text inputs in the
general settings list, display them as dropdowns with common options:
- LlmProvider: openai, claude
- OpenAiModelName: gpt-5-mini, gpt-5.2, gpt-4.1, gpt-4.1-mini, etc.
- ClaudeModelName: claude-sonnet-4-20250514, claude-opus-4-20250514, etc.

This provides better UX by making it easy to switch between known models
without having to remember exact model name strings.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:31:04 -08:00
13e9fb5dad Add self-healing for missing hero name text during game load (#5547)
When loading a game, if a hero's name text (hn_X format) is missing from
the text store, the server would throw an exception requiring the game
to be deleted. This occasionally happened when a hero was created but
the name request was somehow lost before persistence.

Changes:
- New MissingHeroNameRecovery utility: Provides a reusable method to
  check heroes for missing name texts and recreate GeneratedHeroName
  requests. Can be called from game load or other contexts as needed.
- GamesManager: Uses the new utility during game loading to recover
  missing hero names instead of throwing an exception.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:25:46 -08:00
6936c36fc1 Limit chronicle length to ~1.5 pages (#5549)
- Add explicit length instructions to prompt (IMPORTANT prefix)
- Add reminder at end of prompt to be selective
- Increase default word count from 200 to 400 (appropriate for 1.5 pages)
- Instruct LLM to focus on 2-3 most significant events

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:20:38 -08:00
2db4db0ba3 Update March tutorial to mention right-click on map (#5548)
Clarifies that users can right-click a province on the map or use the
dropdown menu to select a destination.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:10:58 -08:00
5abfdc1f09 Improve first-run experience for new users (#5546)
- Default to 1-player game in create game dropdown
- Auto-create 1-player game on first launch (skip lobby)
- Add sign-in tutorial for users without stored accounts
- Add lobby tutorial for users who reach the lobby

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:38:41 -08:00
5c5cf2ce43 Exclude Windows installer from Docker build trigger (#5545)
The installer is built by installer_build.yml, not the Docker workflow.
Adding exclusion prevents unnecessary Docker builds when only installer
files change.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:34:20 -08:00
a31aa0a46d Add icon to WebView installer (#5544)
Generate 64-bit .syso resource file for the WebView installer build.
The previous resource file was 32-bit and caused link errors with the
64-bit CGO cross-compilation.

Changes:
- Add resource_windows_amd64.syso (64-bit COFF object with icon/version)
- Update genrule to copy the .syso file during build

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:28:38 -08:00
63f0119a02 Fix genrule variable syntax for manifest public key (#5542)
* Fix genrule variable syntax for manifest public key

Genrules use Make variable syntax $(VAR), not curly brace syntax {VAR}.
The curly brace syntax only works in x_defs for go_binary rules.

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

* Use --action_env to pass MANIFEST_PUBLIC_KEY to genrule

Bazel genrules don't support workspace status variable substitution
in cmd strings. Instead, use --action_env to pass the environment
variable into the sandbox, where it can be referenced as a regular
shell variable ($$MANIFEST_PUBLIC_KEY in the genrule cmd).

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

* Handle missing MANIFEST_PUBLIC_KEY env var in genrule

When building via 'bazel test //src/main/go/...', the genrule runs
without --action_env=MANIFEST_PUBLIC_KEY set. Use bash default value
syntax ${VAR:-} to default to empty string when the env var isn't set,
allowing the build to succeed with an empty public key.

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

* Simplify public key handling - let empty var expand naturally

Bazel doesn't support ${VAR:-} syntax in genrule cmd. Instead, just
use $MANIFEST_PUBLIC_KEY directly - if not set, it expands to empty
string which the installer handles gracefully.

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

* Add manual tag to exclude webview installer from wildcard builds

The webview installer genrule requires --action_env=MANIFEST_PUBLIC_KEY
to be set. Adding tags = ["manual"] excludes it from wildcard patterns
like "bazel test //src/main/go/..." so it won't fail in the test workflow.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:12:47 -08:00
8436106c7b Fix server not pushing Shardok updates to connected clients (#5543)
PR #5459 accidentally broke Shardok update streaming when refactoring
postBattleUpdate to make Engine protoless. The original code called
withHandledEngineAndResults() which invoked humanClientsAfterPostingResults
to push updates to connected clients. The refactored code updated the
history but skipped client notification.

This caused clients to only receive Shardok updates on initial subscription
or reconnect, not during ongoing gameplay. The heartbeat would detect sync
mismatches (client=X server=Y) but updates weren't pushed.

Fix: Call humanClientsAfterPostingResults in postBattleUpdate to push
Shardok results to connected human clients, restoring the behavior that
was lost during the refactor.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:08:30 -08:00
62f4d40ef3 Enable stamp substitution for WebView installer genrule (#5541)
Add stamp = 1 to the genrule so that {STABLE_MANIFEST_PUBLIC_KEY}
gets substituted with the actual value from workspace status.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:49:15 -08:00
5a650cb9b7 Fix Shardok updates not received after initial subscription (#5540)
When the client subscribes with ShardokViewStatuses empty (because
ShardokGameModels hasn't been populated yet), the server sends an
initial ShardokActionResultResponse as catch-up but doesn't know to
keep sending updates for that game.

The model is created when processing ShardokActionResultResponse, but
by then the subscription was already sent without it. The server
won't send further updates, causing sync mismatches detected by
heartbeat until the client reconnects.

Fix: When creating a new ShardokGameModel from ShardokActionResultResponse,
re-subscribe to tell the server to include this game in future updates.

This issue was exposed by commit fb2b0beb85 which added
ShardokGameModels.Clear() in HandleStartingState, ensuring models are
always empty when subscription is initially sent after reconnect.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:49:03 -08:00
643765a2ba Fix unit placement tutorial text to reflect default placement (#5539)
Units are auto-placed in starting positions by default, so the
tutorial should explain how to move them rather than implying
you need to place them from the Unplaced Units panel first.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:39:26 -08:00
91553a0eec Fix installer manifest public key injection (#5537)
The manifest public key wasn't being injected properly because:
1. Workspace status variables need STABLE_ prefix for x_defs stamping
2. BUILD.bazel was using {MANIFEST_PUBLIC_KEY} but the variable
   wasn't being output with the STABLE_ prefix

This caused the installer to try to base64 decode the literal string
"{MANIFEST_PUBLIC_KEY}" instead of the actual public key value.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:29:25 -08:00
e1384fffd1 Fix Observe Battle button not appearing due to null GameStatus (#5538)
When a ShardokGameModel is first created via MakeGameModel, its
GameStatus property is null. It's only set later when history
entries are processed with a non-null gsvDiff.GameStatus.

The ShardokGameModelIsRunning check accessed sgm.GameStatus.State
without a null check, causing a NullReferenceException when
filtering ShardokGameModels. This caused RunningShardokGameModels
to fail and return empty, making the Observe Battle button not appear.

Fix: Add null check before accessing GameStatus.State, matching the
pattern already used in ShardokGameModel.InSetUp property.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:28:16 -08:00
1f3c4bfef0 Fix pending command retry loop when token is null (#5536)
When CurrentEagleToken or CurrentShardokToken is null (e.g., during
battles), TryPendingCommands was calling PostRequest which:
1. Adds the command back to _pendingCommands
2. Sends it to the server
3. Server returns BAD_TOKEN
4. Next game update triggers TryPendingCommands again
5. Command is still pending with null token → retry loop

This caused the same command to be posted multiple times, flooding the
server with BAD_TOKEN errors and potentially causing reconnect storms.

Fix: When token is null, add the command back to _pendingCommands
directly without calling PostRequest. The command stays pending until
we have a valid token to compare against.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:25:13 -08:00
bfd48bd628 Rename installer to Eagle0.exe and use WebView GUI (#5535)
- Rename WebView installer output from EagleInstallerWebView.exe to Eagle0.exe
- Update CI workflow to build and deploy Eagle0.exe
- Update all references in authservice (download URLs, batch scripts, HTML)
- Update installer_build_handler default key and version content
- Update help text and versioninfo.json metadata

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:08:56 -08:00
12bd6a78b1 Add WebView GUI to Windows installer (#5532)
* Add WebView GUI to Windows installer

Adds a native GUI window to the Windows installer using the webview
library which embeds Microsoft Edge WebView2. The installer now shows
a modern HTML/CSS/JS interface with progress bar and status updates.

Key changes:
- Add llvm-mingw toolchain for macOS → Windows CGO cross-compilation
- Add webview_go dependency for WebView2 integration
- Create UIInterface abstraction with ConsoleUI and WebViewUI impls
- Build tags select stub (non-Windows/non-CGO) vs real WebView impl
- Genrule handles CGO cross-compilation within Bazel sandbox

Build targets:
- eagle_installer_windows_amd64: Pure Go console version (5.8 MB)
- eagle_installer_windows_amd64_webview: CGO WebView GUI (13.7 MB)

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

* Fix WebView genrule to use Bazel Go SDK

The CI doesn't have Go in PATH, so the genrule needs to use Bazel's
Go SDK explicitly. This change:

- Exposes @go_default_sdk via use_repo in MODULE.bazel
- Adds @go_default_sdk//:files as a srcs dependency to the genrule
- Uses absolute paths for GO and GOROOT so they work after cd
- Filters out Go SDK files when copying source files
- Updates go.mod version to 1.23 to match the Bazel SDK

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:36:21 -08:00
420edb15ae Fix Bazel test workflow to show failure on correct step (#5533)
Remove continue-on-error from "Run tests" step so GitHub UI expands
the actual failing step instead of "Fail if tests failed".

The log collection and artifact upload steps use `if: always()` so
they still run after test failures.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:22:05 -08:00
8c0ac6e0fa Use fatigue instead of absolute vigor for hero selection (#5534)
Change hero selection in several commands to use HeroUtils.fatigue
(constitution - vigor) instead of absolute vigor. This is more
consistent with how other commands select heroes and properly accounts
for heroes having different max vigor (constitution).

Commands updated:
- HandleRiotCrackDown in CommandChoiceHelpers
- TruceOfferCommandSelector
- AllianceOfferCommandSelector
- Recon in MidGameAIClient

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:18:44 -08:00
81ce184e17 Improve captured hero plea prompt variety (#5531)
Add relationship context and personality guidance to prevent repetitive
"If you mean to kill me, do it swiftly" patterns:

- Detect if hero was previously in captor's faction (defection context)
- Note previous captures by the same faction
- Reference prior battles between the parties
- Use hero's personality words to guide tone
- Explicitly suggest varied emotional approaches and discourage clichés

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:00:16 -08:00
9a4324e302 Change OAuth buttons to two-column layout (#5530)
Match Unity client layout: left column (Discord, GitHub, Steam),
right column (Google, Apple, Twitch). Includes responsive fallback
to single column on narrow screens.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:49:58 -08:00
473f83e5f6 Complete deproto migration: delete plan and update linter (#5529)
The library/ code is now fully protoless (zero Scala proto dependencies).
Transitive deps through C++/Go build tools (map generation) are expected.

Changes:
- Delete docs/DEPROTO_PLAN.md - migration complete
- Delete scripts/build_deps_baseline.txt - no longer needed
- Update check_build_deps.sh to enforce zero Scala proto deps in library/
  (C++/Go proto deps are allowed as they're build-time tools)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:26:57 -08:00
7d7fa53f46 Remove deprecated .NET installer code (#5528)
The Windows installer has been rewritten in Go. Remove the old C#/.NET
installer code and the associated CI build script.

Deleted:
- src/main/csharp/net/eagle0/clients/win/ - .NET installer source
- ci/build_eagle_installer_win.sh - old manual build script

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:57:53 -08:00
56df35a3b7 Clean up backup installer after successful self-update (#5527)
After the installer updates itself, the old version is left behind as
EagleInstaller.exe.backup. Now on startup, the installer checks for and
removes this backup file.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:55:47 -08:00
b93a2ad1ce Remove stale proto_matchers dependencies from library tests (#5526)
The perform_province_events_action_test and perform_vassal_commands_phase_action_test
had BUILD.bazel dependencies on proto_matchers but the Scala files don't actually
use ProtoMatchers or equalProto. Remove these dead dependencies.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:52:45 -08:00
8213c733a5 Simplify installer: Go only, v2 manifest only (#5525)
Remove .NET installer build and old manifest support. The Go installer is
now the only version built and deployed. Both the installer and Unity
builds now update only the v2 manifest at installer/v2/eagle0_manifest.txt.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:50:32 -08:00
104 changed files with 2568 additions and 2104 deletions
-5
View File
@@ -36,8 +36,6 @@ jobs:
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Run tests
id: test
continue-on-error: true
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
if: always()
@@ -86,6 +84,3 @@ jobs:
path: failed_test_logs/
if-no-files-found: ignore
retention-days: 5
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
+9 -1
View File
@@ -6,9 +6,11 @@ on:
paths:
# Note: C++ changes trigger shardok_arm64_build.yml instead
# Note: Auth changes trigger auth_build.yml instead
# Note: Windows installer changes trigger installer_build.yml instead
- 'src/main/go/**'
- '!src/main/go/net/eagle0/authservice/**'
- '!src/main/go/net/eagle0/authcli/**'
- '!src/main/go/net/eagle0/clients/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
@@ -159,6 +161,8 @@ jobs:
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
@@ -213,7 +217,7 @@ jobs:
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
- name: Deploy to production droplet
@@ -277,6 +281,8 @@ jobs:
export ADMIN_IMAGE="${ADMIN_IMAGE}"
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
export GEMINI_API_KEY="${GEMINI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
@@ -368,6 +374,8 @@ jobs:
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
# Create eagle-exec alias (symlink in /usr/local/bin)
sudo ln -sf /opt/eagle0/scripts/eagle-exec.sh /usr/local/bin/eagle-exec
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
+12 -11
View File
@@ -37,15 +37,16 @@ jobs:
exit 1
fi
# Build Windows installer with manifest public key injected via x_defs
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64 --stamp
# Build Windows installer with WebView GUI (uses CGO cross-compilation)
# Use --action_env to pass the signing key into the genrule sandbox
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview --stamp --action_env=MANIFEST_PUBLIC_KEY
# Copy to output directory
rm -rf ./installer-output
mkdir -p ./installer-output
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/EagleInstaller.exe ./installer-output/EagleInstaller.exe
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/Eagle0.exe ./installer-output/Eagle0.exe
echo "Go installer size: $(ls -lh ./installer-output/EagleInstaller.exe | awk '{print $5}')"
echo "Go installer size: $(ls -lh ./installer-output/Eagle0.exe | awk '{print $5}')"
- name: Archive installer binary
if: success() || failure()
@@ -61,8 +62,8 @@ jobs:
echo "=== Installer output directory ==="
ls -lh ./installer-output/
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
echo "ERROR: EagleInstaller.exe not found"
if [ ! -f "./installer-output/Eagle0.exe" ]; then
echo "ERROR: Eagle0.exe not found"
exit 1
fi
echo "Installer found"
@@ -73,9 +74,9 @@ jobs:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
echo "Deploying Go installer to installer/EagleInstaller.exe"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
INSTALLER_PATH="$(pwd)/installer-output/Eagle0.exe"
echo "Deploying Go installer to installer/Eagle0.exe"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH" "installer/Eagle0.exe"
- name: Update manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
@@ -84,9 +85,9 @@ jobs:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
INSTALLER_SHA=$(sha256sum ./installer-output/Eagle0.exe | cut -d' ' -f1)
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "installer_url=installer/Eagle0.exe" >> /tmp/installer_manifest.txt
echo "=== Manifest content ==="
cat /tmp/installer_manifest.txt
+15
View File
@@ -107,6 +107,7 @@ bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
use_repo(go_sdk, "go_default_sdk")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
@@ -118,6 +119,7 @@ use_repo(
"com_github_aws_aws_sdk_go_v2_service_s3",
"com_github_golang_jwt_jwt_v5",
"com_github_google_uuid",
"com_github_webview_webview_go",
"org_golang_google_grpc",
"org_golang_google_protobuf",
)
@@ -336,6 +338,19 @@ http_file(
executable = True,
)
# LLVM MinGW toolchain for Windows cross-compilation from macOS
# This provides a complete toolchain for building Windows executables including
# the MinGW-w64 libraries needed for CGO cross-compilation
LLVM_MINGW_VERSION = "20250305"
http_archive(
name = "llvm_mingw",
build_file = "@//external:BUILD.llvm_mingw",
sha256 = "32c24fc62fc8b9f8a900bf2c730b78b36767688f816f9d21e97a168289ff44e0",
strip_prefix = "llvm-mingw-%s-ucrt-macos-14.4.1-universal" % LLVM_MINGW_VERSION,
urls = ["https://github.com/fathonix/llvm-mingw-arm64ec-macos/releases/download/%s/llvm-mingw-%s-ucrt-macos-14.4.1-universal.tar.xz" % (LLVM_MINGW_VERSION, LLVM_MINGW_VERSION)],
)
#
# Toolchain Registration
#
+561
View File
@@ -611,6 +611,445 @@
]
}
},
"@@aspect_rules_js~//npm:extensions.bzl%pnpm": {
"general": {
"bzlTransitiveDigest": "T22SdPzhLxF1CM+j9RD/Rq03yJ0NDfH2eE6hAbUcOII=",
"usagesDigest": "6rWte4KDbiluq1s7w98bc4+2NjA8w67DKHDj4+DNw/Y=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"pnpm": {
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
"ruleClassName": "npm_import_rule",
"attributes": {
"package": "pnpm",
"version": "8.6.7",
"root_package": "",
"link_workspace": "",
"link_packages": {},
"integrity": "sha512-vRIWpD/L4phf9Bk2o/O2TDR8fFoJnpYrp2TKqTIZF/qZ2/rgL3qKXzHofHgbXsinwMoSEigz28sqk3pQ+yMEQQ==",
"url": "",
"commit": "",
"patch_args": [
"-p0"
],
"patches": [],
"custom_postinstall": "",
"npm_auth": "",
"npm_auth_basic": "",
"npm_auth_username": "",
"npm_auth_password": "",
"lifecycle_hooks": [],
"extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])",
"generate_bzl_library_targets": false,
"extract_full_archive": true,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
"pnpm__links": {
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
"ruleClassName": "npm_import_links",
"attributes": {
"package": "pnpm",
"version": "8.6.7",
"dev": false,
"root_package": "",
"link_packages": {},
"deps": {},
"transitive_closure": {},
"lifecycle_build_target": false,
"lifecycle_hooks_env": [],
"lifecycle_hooks_execution_requirements": [
"no-sandbox"
],
"lifecycle_hooks_use_default_shell_env": false,
"bins": {},
"package_visibility": [
"//visibility:public"
],
"replace_package": "",
"exclude_package_contents": []
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_js~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_features",
"bazel_features~"
],
[
"aspect_rules_js~",
"bazel_skylib",
"bazel_skylib~"
],
[
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_features_globals",
"bazel_features~~version_extension~bazel_features_globals"
],
[
"bazel_features~",
"bazel_features_version",
"bazel_features~~version_extension~bazel_features_version"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
},
"@@aspect_rules_ts~//ts:extensions.bzl%ext": {
"general": {
"bzlTransitiveDigest": "h1hftyFCdJgiHD9blfFcMAiEk5ltEoUdNkuHPIkg/hM=",
"usagesDigest": "v0aTa4/gasWF2bvssXYr1bqcYWc3kjV48hcj0z2QVT0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"npm_typescript": {
"bzlFile": "@@aspect_rules_ts~//ts/private:npm_repositories.bzl",
"ruleClassName": "http_archive_version",
"attributes": {
"bzlmod": true,
"version": "5.8.3",
"integrity": "",
"build_file": "@@aspect_rules_ts~//ts:BUILD.typescript",
"build_file_substitutions": {
"bazel_worker_version": "5.4.2",
"google_protobuf_version": "3.20.1"
},
"urls": [
"https://registry.npmjs.org/typescript/-/typescript-{}.tgz"
]
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_rules_ts~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@cel-spec~//:extensions.bzl%non_module_dependencies": {
"general": {
"bzlTransitiveDigest": "/2uyuQa5purSharolRXYOGYMGSGjDeByo6JfQDWsraA=",
"usagesDigest": "2f6juplOpWu+UdD1kVgi773xavnFQ+OcH0PRuQduDxY=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_google_googleapis": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"sha256": "bd8e735d881fb829751ecb1a77038dda4a8d274c45490cb9fcf004583ee10571",
"strip_prefix": "googleapis-07c27163ac591955d736f3057b1619ece66f5b99",
"urls": [
"https://github.com/googleapis/googleapis/archive/07c27163ac591955d736f3057b1619ece66f5b99.tar.gz"
]
}
}
},
"recordedRepoMappingEntries": [
[
"cel-spec~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@cel-spec~//:googleapis_ext.bzl%googleapis_ext": {
"general": {
"bzlTransitiveDigest": "yun2jmsomFi3bs5bjQWXApBzqQf66zBJ39JEBYigzdc=",
"usagesDigest": "OK8FsLndSl2AbwGM1Npe5NdHR1kDAebcw7Ee+KkekE0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_google_googleapis_imports": {
"bzlFile": "@@cel-spec~~non_module_dependencies~com_google_googleapis//:repository_rules.bzl",
"ruleClassName": "switched_rules",
"attributes": {
"rules": {
"proto_library_with_info": [
"",
""
],
"moved_proto_library": [
"",
""
],
"java_proto_library": [
"",
""
],
"java_grpc_library": [
"",
""
],
"java_gapic_library": [
"",
""
],
"java_gapic_test": [
"",
""
],
"java_gapic_assembly_gradle_pkg": [
"",
""
],
"py_proto_library": [
"",
""
],
"py_grpc_library": [
"",
""
],
"py_gapic_library": [
"",
""
],
"py_test": [
"",
""
],
"py_gapic_assembly_pkg": [
"",
""
],
"py_import": [
"",
""
],
"go_proto_library": [
"",
""
],
"go_library": [
"",
""
],
"go_test": [
"",
""
],
"go_gapic_library": [
"",
""
],
"go_gapic_assembly_pkg": [
"",
""
],
"cc_proto_library": [
"native.cc_proto_library",
""
],
"cc_grpc_library": [
"",
""
],
"cc_gapic_library": [
"",
""
],
"php_proto_library": [
"",
"php_proto_library"
],
"php_grpc_library": [
"",
"php_grpc_library"
],
"php_gapic_library": [
"",
"php_gapic_library"
],
"php_gapic_assembly_pkg": [
"",
"php_gapic_assembly_pkg"
],
"nodejs_gapic_library": [
"",
"typescript_gapic_library"
],
"nodejs_gapic_assembly_pkg": [
"",
"typescript_gapic_assembly_pkg"
],
"ruby_proto_library": [
"",
""
],
"ruby_grpc_library": [
"",
""
],
"ruby_ads_gapic_library": [
"",
""
],
"ruby_cloud_gapic_library": [
"",
""
],
"ruby_gapic_assembly_pkg": [
"",
""
],
"csharp_proto_library": [
"",
""
],
"csharp_grpc_library": [
"",
""
],
"csharp_gapic_library": [
"",
""
],
"csharp_gapic_assembly_pkg": [
"",
""
]
}
}
}
},
"recordedRepoMappingEntries": [
[
"cel-spec~",
"com_google_googleapis",
"cel-spec~~non_module_dependencies~com_google_googleapis"
]
]
}
},
"@@envoy_api~//bazel:repositories.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "6TqmRfVELxZJRPQYuJpC4JBX4QvdrHqTDOUJGOGODSo=",
"usagesDigest": "IivjlawPvqhHPUJ3c6dLiPsH22mn/g/dJtlmv3zdimM=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"prometheus_metrics_model": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/prometheus/client_model/archive/v0.6.1.tar.gz"
],
"sha256": "b9b690bc35d80061f255faa7df7621eae39fe157179ccd78ff6409c3b004f05e",
"strip_prefix": "client_model-0.6.1",
"build_file_content": "\nload(\"@envoy_api//bazel:api_build_system.bzl\", \"api_cc_py_proto_library\")\nload(\"@io_bazel_rules_go//proto:def.bzl\", \"go_proto_library\")\n\napi_cc_py_proto_library(\n name = \"client_model\",\n srcs = [\n \"io/prometheus/client/metrics.proto\",\n ],\n visibility = [\"//visibility:public\"],\n)\n\ngo_proto_library(\n name = \"client_model_go_proto\",\n importpath = \"github.com/prometheus/client_model/go\",\n proto = \":client_model\",\n visibility = [\"//visibility:public\"],\n)\n"
}
},
"com_github_bufbuild_buf": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/bufbuild/buf/releases/download/v1.49.0/buf-Linux-x86_64.tar.gz"
],
"sha256": "ee8da9748249f7946d79191e36469ce7bc3b8ba80019bff1fa4289a44cbc23bf",
"strip_prefix": "buf",
"build_file_content": "\npackage(\n default_visibility = [\"//visibility:public\"],\n)\n\nfilegroup(\n name = \"buf\",\n srcs = [\n \"@com_github_bufbuild_buf//:bin/buf\",\n ],\n tags = [\"manual\"], # buf is downloaded as a linux binary; tagged manual to prevent build for non-linux users\n)\n"
}
},
"envoy_toolshed": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/envoyproxy/toolshed/archive/bazel-v0.2.2.tar.gz"
],
"sha256": "443fe177aba0cef8c17b7a48905c925c67b09005b10dd70ff12cd9f729a72d51",
"strip_prefix": "toolshed-bazel-v0.2.2/bazel"
}
}
},
"recordedRepoMappingEntries": [
[
"envoy_api~",
"bazel_tools",
"bazel_tools"
],
[
"envoy_api~",
"envoy_api",
"envoy_api~"
]
]
}
},
"@@googleapis~//:extensions.bzl%switched_rules": {
"general": {
"bzlTransitiveDigest": "vG6fuTzXD8MMvHWZEQud0MMH7eoC4GXY0va7VrFFh04=",
@@ -779,6 +1218,37 @@
"recordedRepoMappingEntries": []
}
},
"@@pybind11_bazel~//:internal_configure.bzl%internal_configure_extension": {
"general": {
"bzlTransitiveDigest": "CyAKLVVonohnkTSqg9II/HA7M49sOlnMkgMHL3CmDuc=",
"usagesDigest": "mFrTHX5eCiNU/OIIGVHH3cOILY9Zmjqk8RQYv8o6Thk=",
"recordedFileInputs": {
"@@pybind11_bazel~//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"pybind11": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"build_file": "@@pybind11_bazel~//:pybind11-BUILD.bazel",
"strip_prefix": "pybind11-2.12.0",
"urls": [
"https://github.com/pybind/pybind11/archive/v2.12.0.zip"
]
}
}
},
"recordedRepoMappingEntries": [
[
"pybind11_bazel~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_foreign_cc~//foreign_cc:extensions.bzl%tools": {
"general": {
"bzlTransitiveDigest": "a7qnESofmIRYId6wwGNPJ9kvExU80KrkxL281P3+lBE=",
@@ -1119,6 +1589,97 @@
]
}
},
"@@rules_fuzzing~//fuzzing/private:extensions.bzl%non_module_dependencies": {
"general": {
"bzlTransitiveDigest": "hVgJRQ3Er45/UUAgNn1Yp2Khcp/Y8WyafA2kXIYmQ5M=",
"usagesDigest": "YnIrdgwnf3iCLfChsltBdZ7yOJh706lpa2vww/i2pDI=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"platforms": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz",
"https://github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz"
],
"sha256": "8150406605389ececb6da07cbcb509d5637a3ab9a24bc69b1101531367d89d74"
}
},
"rules_python": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"sha256": "d70cd72a7a4880f0000a6346253414825c19cdd40a28289bdf67b8e6480edff8",
"strip_prefix": "rules_python-0.28.0",
"url": "https://github.com/bazelbuild/rules_python/releases/download/0.28.0/rules_python-0.28.0.tar.gz"
}
},
"bazel_skylib": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"sha256": "cd55a062e763b9349921f0f5db8c3933288dc8ba4f76dd9416aac68acee3cb94",
"urls": [
"https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz",
"https://github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz"
]
}
},
"com_google_absl": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/abseil/abseil-cpp/archive/refs/tags/20240116.1.zip"
],
"strip_prefix": "abseil-cpp-20240116.1",
"integrity": "sha256-7capMWOvWyoYbUaHF/b+I2U6XLMaHmky8KugWvfXYuk="
}
},
"rules_fuzzing_oss_fuzz": {
"bzlFile": "@@rules_fuzzing~//fuzzing/private/oss_fuzz:repository.bzl",
"ruleClassName": "oss_fuzz_repository",
"attributes": {}
},
"honggfuzz": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"build_file": "@@rules_fuzzing~//:honggfuzz.BUILD",
"sha256": "6b18ba13bc1f36b7b950c72d80f19ea67fbadc0ac0bb297ec89ad91f2eaa423e",
"url": "https://github.com/google/honggfuzz/archive/2.5.zip",
"strip_prefix": "honggfuzz-2.5"
}
},
"rules_fuzzing_jazzer": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_jar",
"attributes": {
"sha256": "ee6feb569d88962d59cb59e8a31eb9d007c82683f3ebc64955fd5b96f277eec2",
"url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer/0.20.1/jazzer-0.20.1.jar"
}
},
"rules_fuzzing_jazzer_api": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_jar",
"attributes": {
"sha256": "f5a60242bc408f7fa20fccf10d6c5c5ea1fcb3c6f44642fec5af88373ae7aa1b",
"url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer-api/0.20.1/jazzer-api-0.20.1.jar"
}
}
},
"recordedRepoMappingEntries": [
[
"rules_fuzzing~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_java~//java:rules_java_deps.bzl%compatibility_proxy": {
"general": {
"bzlTransitiveDigest": "KIX40nDfygEWbU+rq3nYpt3tVgTK/iO8PKh5VMBlN7M=",
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
/bin/mkdir -p win_output
/usr/bin/dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller.sln -o win_output
SHA=`sha256sum /tmp/EagleInstaller.exe | awk '{print $1 }'`
ZIP_FILE="updater__$SHA.zip"
/usr/bin/zip win_output/$ZIP_FILE win_output/EagleInstaller.exe
rm win_output/EagleInstaller.exe
rm win_output/EagleInstaller.pdb
DATE=`date +"%Y-%m-%d %T"`
cat > win_output/updater.html <<-EOF
<html>
<head>
<title>Download Eagle Updater</title>
</head>
<body>
<a href="http://eagle0.net/assets/$ZIP_FILE">$ZIP_FILE</a> (updated $DATE)
</body>
</html>
EOF
SSH_KEY_FILE=$1
SSH_USER_NAME=$2
/usr/bin/rsync -r --copy-links -e "/usr/bin/ssh -i $SSH_KEY_FILE -p 9022" win_output/ $SSH_USER_NAME@eagle0.net:/www/assets/
+4 -4
View File
@@ -18,8 +18,6 @@ services:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-blue
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS}"
- "--auth-service-url"
@@ -28,6 +26,8 @@ services:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
@@ -67,8 +67,6 @@ services:
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS}"
- "--auth-service-url"
@@ -77,6 +75,8 @@ services:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
-170
View File
@@ -1,170 +0,0 @@
# Deproto Migration Plan
## Vision
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
```
┌─────────────────────────────────────────────────────────────────────┐
│ GRPC BOUNDARY │
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ SCALA ENGINE │
│ │
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
│ ↑ │ │
│ │ (Pure Scala models) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE BOUNDARY │
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Current State (January 2026)
| Area | Status |
|------|--------|
| Production code (`src/main/scala/.../library/`) | ✅ **Complete** - zero proto imports |
| Test code (`src/test/scala/.../library/`) | ✅ **Complete** - 24 migrated, 0 deferred, 4 deleted |
| Hostility proto migration | ✅ **Complete** - all 4 files migrated to Scala enum |
**Note:** The `bazel query` for proto dependencies still shows ~26 targets, but these are transitive build-graph dependencies from map data generation (C++/Go tools), not Scala code imports.
---
## Phase 10: Library Test Migration ✅ Complete
### Overview
27 test files in `src/test/scala/.../library/` were reviewed for proto type usage. 20 files were successfully migrated to use Scala model types exclusively.
### Migration Results
#### Successfully Migrated (20 files)
| File | PR | Notes |
|------|-----|-------|
| `QuestFulfillmentUtilsTest.scala` | Merged | Light migration |
| `FeastCommandTest.scala` | Merged | Light migration |
| `FriendlyMoveActionTest.scala` | Merged | Light migration |
| `DeclineQuestCommandTest.scala` | Merged | Light migration |
| `PerformHostileArmySetupActionTest.scala` | Merged | Medium migration |
| `NewYearActionTest.scala` | Merged | Medium migration |
| `ProvinceHeldActionTest.scala` | Merged | Medium migration |
| `AvailableMarchCommandFactoryTest.scala` | Merged | Medium migration |
| `AvailableCommandsFactoryTest.scala` | Merged | Heavy migration |
| `AvailablePleaseRecruitMeCommandFactoryTest.scala` | Merged | Heavy migration |
| `PerformReconResolutionActionTest.scala` | Merged | Heavy migration |
| `AttackDecisionCommandChooserTest.scala` | Merged | Heavy migration |
| `PerformProvinceEventsActionTest.scala` | #5499 | Heavy migration |
| `PerformFoodConsumptionPhaseActionTest.scala` | #5500 | Heavy migration |
| `AvailableDiplomacyCommandsFactoryTest.scala` | #5502 | Heavy migration |
| `EndPlayerCommandsPhaseActionTest.scala` | #5504 | Heavy migration |
| `ProvinceConqueredActionTest.scala` | #5505 | Heavy migration |
| `NewRoundActionTest.scala` | #5506 | Heavy migration (22 imports) |
| `EngineImplTest.scala` | #5488 | Added InMemoryHistory.fromScalaResults |
| `GameStateViewFilterTest.scala` | #5510 | Heavy migration with view types |
#### Additional Migrations (4 files)
| File | PR | Notes |
|------|-----|-------|
| `BattalionTypesTestData.scala` | hostility-deproto | Migrated to Scala BattalionType |
| `NewRoundActionTest.scala` | hostility-deproto | Heavy migration (FactionRelationship, MovingArmy) |
| `EndBattleAftermathPhaseActionTest.scala` | hostility-deproto | Complete rewrite to Scala types |
| `ResolveBattleActionTest.scala` | hostility-deproto | Minimal migration (removed IDable usage) |
#### Deleted (4 files)
| File | Reason |
|------|--------|
| `RansomCommandTest.scala` | Orphaned test with no corresponding implementation |
| `IDable.scala` | Unused - BUILD deps existed but no code actually imported it |
| `impl/package.scala` | Defined HeroMap/BattalionMap but never used |
| `availability/package.scala` | Defined HeroMap/BattalionMap but never used |
### Key Migration Patterns Used
**Import aliasing to avoid naming conflicts:**
```scala
import net.eagle0.eagle.model.action_result.types.ActionResultType.{
NewRoundAction as NewRoundActionType,
NewYearAction as NewYearActionType
}
```
**Using `inside()` for concrete type access:**
```scala
inside(result.changedProvinces.head) { case cp: ChangedProvinceC =>
cp.foodDelta shouldBe Some(-258)
}
```
**Scala model construction:**
```scala
HeroC(id = 9, vigor = 50, factionId = Some(actingFactionId))
ProvinceC(id = 7, rulingFactionId = Some(5), food = 2987)
FactionC(id = 5, factionHeadId = 9, name = "Test Faction")
```
### Key Conversions Reference
| Proto Type | Scala Equivalent |
|------------|------------------|
| `GameState` | `GameState` (model/state/game_state) |
| `Province` | `ProvinceC` |
| `Hero` | `HeroC` |
| `Faction` | `FactionC` |
| `Battalion` | `BattalionC` |
| `Army` | `Army` (already Scala) |
| `Date` | `Date` (model/state/date) |
| `ChangedHero` | `ChangedHeroC` |
| `ChangedProvince` | `ChangedProvinceC` |
| `ActionResultType.*` | `ActionResultType.*` (Scala enum) |
### Success Criteria
- [x] 24 test files fully migrated to Scala types
- [x] All tests pass
- [x] Deferred files documented with clear rationale
- [x] Zero proto imports in `src/test/scala/.../library/`
---
## Architecture Summary
### Fully Protoless ✅
- **Production library code** (`src/main/scala/.../library/`)
- **AI layer** (`/ai/`)
- **Actions** (`/library/actions/impl/action/`)
- **Commands** (`/library/actions/impl/command/`)
- **Availability** (`/library/actions/availability/`)
- **Command helpers** (`/library/util/command_choice_helpers/`)
- **View filters** (`/library/util/view_filters/`)
- **LLM generators** (`/library/actions/llm_prompt_generators/`)
- **All library tests** (`src/test/scala/.../library/`) - 24 files migrated
### Proto at Boundaries (Expected) ✅
- `EagleServiceImpl.scala` - gRPC boundary
- `GameController.scala` - Client communication
- `*Converter.scala` - Explicit conversion utilities
- `PersistedHistory.scala` - Disk persistence
### Deferred (Documented) 📋
- None! All library test utilities migrated or deleted.
+94
View File
@@ -0,0 +1,94 @@
# LLM Model Comparison
This document compares streaming latency (time-to-first-token) and pricing across OpenAI, Anthropic (Claude), and Google (Gemini) models for use in Eagle's narrative text generation.
## Test Methodology
All tests were performed locally using curl with streaming enabled. Each model was tested 3 times with the same prompt:
> "Write a short paragraph about a brave knight who discovers a hidden cave. Make it vivid and descriptive."
Time-to-first-token (TTFT) was measured from request initiation to the first text content appearing in the stream.
## Streaming Latency Results (January 2026)
| Model | Run 1 | Run 2 | Run 3 | Average TTFT |
|-------|-------|-------|-------|--------------|
| **Gemini 2.5 Flash-Lite** | 0.76s | 0.54s | 0.53s | **~0.6s** |
| **gpt-4.1-mini** | 1.65s | 1.72s | 1.68s | **~1.7s** |
| **claude-3-5-haiku** | 1.85s | 1.92s | 1.88s | **~1.9s** |
| gpt-5.2 | 3.25s | 3.38s | 3.32s | **~3.3s** |
| gpt-5-mini | 2.52s | 5.82s | 3.12s | **~3.8s** (high variance) |
| Gemini 3 Flash Preview | 4.11s | 4.77s | 4.28s | **~4.4s** |
| claude-sonnet-4 | 4.89s | 5.12s | 4.98s | **~5.0s** |
| Gemini 2.5 Flash | 5.60s | 7.00s | 7.79s | **~6.8s** |
## Pricing Comparison (per 1M tokens)
| Model | Input Price | Output Price | Notes |
|-------|-------------|--------------|-------|
| **Gemini 2.5 Flash-Lite** | $0.10 | $0.40 | Cheapest and fastest |
| Gemini 2.5 Flash | $0.15 | $0.60 | |
| gpt-5-mini | $0.25 | $2.00 | |
| **gpt-4.1-mini** | $0.40 | $1.60 | Best OpenAI value |
| Gemini 3 Flash Preview | $0.50 | $3.00 | Includes thinking tokens |
| **claude-3-5-haiku** | $0.80 | $4.00 | Best Anthropic value |
| gpt-5.2 | ~$1.00 | ~$10.00 | Full reasoning model |
| Gemini 2.5 Pro | $1.25 | $10.00 | |
| Gemini 3 Pro Preview | $2.00 | $12.00 | ≤200K context |
| claude-sonnet-4 | $3.00 | $15.00 | |
## Recommendations
### For Narrative Text Generation (Default)
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
### Quality Trade-offs
For short narrative snippets (1-3 paragraphs):
- **Flash-Lite vs Haiku/4.1-mini**: Minor quality difference, significant speed gain
- **Haiku vs Sonnet**: Noticeable quality difference in creative writing variety
- **gpt-4.1-mini vs gpt-5.2**: Moderate quality difference, significant cost savings
## Configuration
LLM settings can be changed at runtime via the admin console:
1. Navigate to Admin Console → Settings
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
3. Change the corresponding model name setting:
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
Changes take effect on the next LLM request.
## Environment Variables
For production deployment, ensure API keys are set:
```bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=AIza...
```
## Notes
- **gpt-5-mini** showed high latency variance (2.5s - 5.8s) in testing
- **Gemini 2.5 Flash** was surprisingly slower than Flash-Lite, possibly due to internal reasoning overhead
- **Gemini 3 Flash** is a frontier model with better quality but higher latency than 2.5 Flash-Lite
- All Gemini models have a generous free tier (up to 1,000 daily requests)
+48
View File
@@ -0,0 +1,48 @@
# LLVM MinGW toolchain for Windows cross-compilation
# Provides x86_64-w64-mingw32 target compiler and libraries
package(default_visibility = ["//visibility:public"])
filegroup(
name = "all_files",
srcs = glob(["**/*"]),
)
# Compiler binaries
filegroup(
name = "compiler_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/clang*",
"bin/llvm-*",
"bin/lld*",
]),
)
# Windows x86_64 sysroot (headers and libraries)
filegroup(
name = "windows_x86_64_sysroot",
srcs = glob([
"x86_64-w64-mingw32/**/*",
"generic-w64-mingw32/include/**/*",
]),
)
# All library files needed for linking
filegroup(
name = "linker_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/lld*",
"bin/ld.lld*",
"lib/**/*",
"x86_64-w64-mingw32/lib/**/*",
]),
)
# The main C compiler wrapper script path for CGO
# CGO needs CC to point to the cross-compiler
exports_files([
"bin/x86_64-w64-mingw32-clang",
"bin/x86_64-w64-mingw32-clang++",
])
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.36.3
)
+2
View File
@@ -41,6 +41,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-3
View File
@@ -1,3 +0,0 @@
# BUILD dependency baseline - updated Tue Jan 20 21:22:24 PST 2026
# Do not increase these numbers - only decrease!
library_proto_deps=26
+27 -80
View File
@@ -6,16 +6,12 @@
# ./scripts/check_build_deps.sh # Check all rules
# ./scripts/check_build_deps.sh --ci # CI mode (fail on any violation)
# ./scripts/check_build_deps.sh --count # Just count current violations (for tracking progress)
# ./scripts/check_build_deps.sh --strict # Fail if proto deps in library/ exceed baseline
#
# The baseline for proto deps is stored in scripts/build_deps_baseline.txt
# Update it with: ./scripts/check_build_deps.sh --update-baseline
# ./scripts/check_build_deps.sh --strict # Same as --ci (strict enforcement)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BASELINE_FILE="$SCRIPT_DIR/build_deps_baseline.txt"
cd "$REPO_ROOT"
RED='\033[0;31m'
@@ -26,15 +22,6 @@ NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Get baseline proto count (default to a high number if no baseline exists)
get_baseline() {
if [ -f "$BASELINE_FILE" ]; then
grep "^library_proto_deps=" "$BASELINE_FILE" | cut -d= -f2
else
echo "999999" # No baseline = don't fail
fi
}
# Rule 1: src/main should not depend on src/test
check_main_depends_on_test() {
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
@@ -51,26 +38,24 @@ check_main_depends_on_test() {
fi
}
# Rule 2: library/ should not depend on src/main/protobuf
# Note: This rule is aspirational - there are currently many violations as part of ongoing deproto effort
check_library_depends_on_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on src/main/protobuf...${NC}"
# Rule 2: library/ should not depend on Scala proto types
# C++/Go proto deps are allowed (they're build-time deps for map generation tools)
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null || true)
count=$(echo "$violations" | grep -c "^//" || echo "0")
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$violations" ]; then
count=0
else
count=$(echo "$violations" | grep -c "^//" || true)
fi
if [ "$count" -gt 0 ]; then
echo -e "${YELLOW}Found $count proto dependencies in library/ (deproto in progress)${NC}"
if [ "$MODE" == "--count" ]; then
echo "$violations" | head -20
if [ "$count" -gt 20 ]; then
echo "... and $((count - 20)) more"
fi
fi
# Don't fail on this rule yet - it's aspirational
return 0
echo -e "${RED}VIOLATION: Found $count Scala proto dependencies in library/:${NC}"
echo "$violations"
return 1
else
echo -e "${GREEN}✓ No proto dependencies in library/${NC}"
echo -e "${GREEN}✓ No Scala proto dependencies in library/${NC}"
return 0
fi
}
@@ -96,51 +81,21 @@ check_library_depends_on_proto_converters() {
fi
}
# Count proto deps for tracking deproto progress
# Count proto deps for informational purposes
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
library_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep -c "^//" || echo "0")
echo "library/: $library_count proto dependencies"
baseline=$(get_baseline)
if [ "$baseline" != "999999" ]; then
echo "baseline: $baseline"
if [ "$library_count" -lt "$baseline" ]; then
echo -e "${GREEN}↓ Reduced by $((baseline - library_count)) from baseline${NC}"
elif [ "$library_count" -gt "$baseline" ]; then
echo -e "${RED}↑ Increased by $((library_count - baseline)) from baseline${NC}"
fi
fi
}
# Check if proto deps exceed baseline (for strict mode)
check_proto_baseline() {
library_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep -c "^//" || echo "0")
baseline=$(get_baseline)
echo -e "${YELLOW}Checking: proto deps in library/ should not exceed baseline...${NC}"
echo "Current: $library_count, Baseline: $baseline"
if [ "$library_count" -gt "$baseline" ]; then
echo -e "${RED}VIOLATION: Proto dependencies increased from $baseline to $library_count${NC}"
echo "Run './scripts/check_build_deps.sh --count' to see which protos are used"
return 1
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$scala_proto_results" ]; then
scala_proto_count=0
else
echo -e "${GREEN}✓ Proto deps at or below baseline${NC}"
return 0
scala_proto_count=$(echo "$scala_proto_results" | wc -l | tr -d ' ')
fi
}
echo "library/ Scala proto deps: $scala_proto_count"
# Update baseline file
update_baseline() {
library_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep -c "^//" || echo "0")
echo "# BUILD dependency baseline - updated $(date)" > "$BASELINE_FILE"
echo "# Do not increase these numbers - only decrease!" >> "$BASELINE_FILE"
echo "library_proto_deps=$library_count" >> "$BASELINE_FILE"
echo -e "${GREEN}Updated baseline: library_proto_deps=$library_count${NC}"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
}
echo "=== BUILD.bazel Dependency Check ==="
@@ -150,22 +105,14 @@ case "$MODE" in
--count)
count_proto_deps
;;
--ci)
--ci|--strict)
check_main_depends_on_test || EXIT_CODE=1
check_library_depends_on_proto || EXIT_CODE=1
check_library_depends_on_scala_proto || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
;;
--strict)
check_main_depends_on_test || EXIT_CODE=1
check_proto_baseline || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
;;
--update-baseline)
update_baseline
;;
*)
check_main_depends_on_test || EXIT_CODE=1
check_library_depends_on_proto
check_library_depends_on_scala_proto || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
echo ""
count_proto_deps
+5
View File
@@ -35,6 +35,7 @@ WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
# Colors for output
RED='\033[0;31m'
@@ -298,6 +299,10 @@ main() {
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Write active instance file for eagle-exec helper
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
#
# Helper to run docker exec against the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-exec printenv GEMINI_API_KEY
# eagle-exec jcmd 1 VM.flags
# eagle-exec sh # Get a shell
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
if [ $# -eq 0 ]; then
echo "Active instance: $ACTIVE"
echo "Usage: $0 <command> [args...]"
echo "Example: $0 printenv GEMINI_API_KEY"
exit 0
fi
exec docker exec "$ACTIVE" "$@"
@@ -296,6 +296,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Refresh stored account buttons
RefreshStoredAccountButtons();
// Show sign-in tutorial for new users who have no stored accounts
var accounts = OAuthManager.Instance?.GetStoredAccounts();
if (accounts == null || accounts.Count == 0) {
TutorialManager.Instance?.OnGameEvent("auth_panel_shown");
}
}
private void RefreshStoredAccountButtons() {
@@ -718,9 +724,64 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Clear loading indicator now that lobby is populated
if (lobbyStatusText != null) { lobbyStatusText.text = ""; }
// First-run experience: check if this is truly the first time
// If user has no running games and has never completed onboarding,
// auto-create a 1-player game for them
if (!_hasAutoCreatedFirstGame && ShouldAutoCreateFirstGame(lobbyResponse)) {
_hasAutoCreatedFirstGame = true;
Debug.Log(
"[ConnectionHandler] First launch detected - auto-creating 1-player game");
AutoCreateFirstGame();
} else {
// Trigger lobby tutorial for users who haven't seen it
TutorialManager.Instance?.OnGameEvent("lobby_entered");
}
});
}
// Track whether we've already auto-created a first game this session
private bool _hasAutoCreatedFirstGame = false;
/// <summary>
/// Check if we should auto-create a first game for a new user.
/// Returns true if: no running games, and onboarding not completed.
/// </summary>
private bool ShouldAutoCreateFirstGame(LobbyResponse lobbyResponse) {
// Check if user has any running games
if (lobbyResponse.RunningGames.Count > 0) return false;
// Check if tutorial system indicates this is a first-time user
var tutorialManager = TutorialManager.Instance;
if (tutorialManager == null) return false;
// If onboarding is complete, they've played before
if (tutorialManager.State.OnboardingCompleted) return false;
// Check if they've already seen the lobby tutorial
if (tutorialManager.State.HasCompletedTutorial("lobby_intro")) return false;
return true;
}
/// <summary>
/// Auto-create and launch a 1-player game for first-time users.
/// </summary>
private void AutoCreateFirstGame() {
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
Debug.LogWarning("[ConnectionHandler] Cannot auto-create game - no leaders available");
return;
}
// Pick a random leader
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
// Create a 1-player, 1-human game (solo vs AI)
Debug.Log($"[ConnectionHandler] Auto-creating 1-player game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 1);
}
private void StartListeningForLobbyUpdates() {
lock (pendingReplyLock) {
_persistentClientConnection.SetLobbySubscriber(this);
@@ -79,9 +79,11 @@ public class CreateGameItem : MonoBehaviour {
totalPlayersDropdown.ClearOptions();
totalPlayersDropdown.AddOptions(
Enumerable.Range(1, maxPlayerCount).Select(i => i.ToString()).ToList());
// Default to 7 total players (index 6 = 7 players, so 1 human + 6 AI)
totalPlayersDropdown.value = Math.Min(6, maxPlayerCount - 1);
SetHumanPlayersDropdown();
totalHumansDropdown.value = Math.Min(totalPlayersDropdown.value, 1);
// Default to 1 human (index 0)
totalHumansDropdown.value = 0;
}
}
@@ -45,8 +45,9 @@ namespace eagle {
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
|| sgm.GameStatus.State == GameStatus.Types.State.SetUp;
sgm.GameStatus != null &&
(sgm.GameStatus.State == GameStatus.Types.State.GameRunning ||
sgm.GameStatus.State == GameStatus.Types.State.SetUp);
Dictionary<String, ShardokGameModel> RunningShardokGameModels =>
ShardokGameModels.Where(kv => ShardokGameModelIsRunning(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value);
@@ -340,6 +341,9 @@ namespace eagle {
// can see the final battle results before the models are removed.
var endedGames = new List<ShardokGameId>();
// Track if we created new models that require re-subscription
bool createdNewModels = false;
foreach (var oneResponse in specResponse) {
if (!_currentModel.ShardokGameModels.TryGetValue(
oneResponse.ShardokGameId,
@@ -356,6 +360,12 @@ namespace eagle {
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
continue;
}
// New model created - we need to re-subscribe so the server
// knows to keep sending updates for this game
createdNewModels = true;
_connectionLogger.LogLine(
$"[SHARDOK] Created new model for {oneResponse.ShardokGameId}, will re-subscribe");
}
var updatesOk = shardokGameModel.HandleUpdates(
@@ -387,6 +397,15 @@ namespace eagle {
}
}
// If we created new models, re-subscribe so the server knows to keep
// sending updates for these games. The original subscription didn't
// include them because ShardokGameModels was empty at subscribe time.
if (createdNewModels) {
_connectionLogger.LogLine(
"[SHARDOK] Re-subscribing to include newly created Shardok models");
_ = StartListeningForUpdates();
}
// Invoke UI callback BEFORE removing ended games, so the UI can
// see the final battle results (with GameStatus = Victory/Defeat)
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
@@ -511,13 +511,13 @@ namespace eagle {
lock (this) { _pendingCommands.Add(nextCommand); }
}
} else {
// CurrentEagleToken is null - server hasn't sent us
// commands yet, or we skipped them. Retry the pending
// command anyway; if the server already processed it, it
// will be ignored as duplicate.
// CurrentEagleToken is null - we're in a battle or haven't
// received commands yet. Keep the command pending without
// retrying to avoid flooding the server with BAD_TOKEN
// errors.
_remoteEagleClientLogger.LogLine(
$"No current token, retrying pending command with token {providedToken}");
await PostRequest(nextCommand);
$"No current token, keeping pending command with token {providedToken} for later");
lock (this) { _pendingCommands.Add(nextCommand); }
}
break;
@@ -533,10 +533,11 @@ namespace eagle {
shardokToken);
await PostRequest(nextCommand);
} else if (currentShardokToken == null) {
// No current token - retry anyway
// No current token - keep pending without retrying to avoid
// flooding the server with BAD_TOKEN errors.
_remoteEagleClientLogger.LogLine(
$"No current Shardok token, retrying pending command with token {providedShardokToken}");
await PostRequest(nextCommand);
$"No current Shardok token, keeping pending command with token {providedShardokToken} for later");
lock (this) { _pendingCommands.Add(nextCommand); }
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok token mismatch: pending={providedShardokToken} " +
@@ -33,6 +33,9 @@ namespace Eagle0.Tutorial {
// Register Shardok (tactical battle) tutorials
RegisterShardokTutorials(registry);
// Register lobby tutorials (pre-game)
RegisterLobbyTutorials(registry);
}
// ========== ONBOARDING SEQUENCE ==========
@@ -304,7 +307,7 @@ namespace Eagle0.Tutorial {
"command_march",
"March",
"March moves your army to another province.\n\n" +
"• Select a <b>destination</b> province\n" +
"• <b>Right-click a province</b> on the map, or select from the <b>dropdown menu</b>\n" +
"• Choose which <b>heroes and battalions</b> to take\n" +
"• Optionally bring <b>supplies</b> (food and gold)\n\n" +
"If you march into enemy territory, battle may ensue!");
@@ -861,7 +864,7 @@ namespace Eagle0.Tutorial {
"shardok_unit_placement",
"Place Your Units",
"Before battle begins, position your army on the battlefield.\n\n" +
"Click a unit in the <b>Unplaced Units</b> panel on the right, then click a <b>highlighted hex</b> to place it.\n\n" +
"Your units are placed in starting positions. <b>Click a unit</b> to select it, then <b>click another highlighted hex</b> to move it.\n\n" +
"Right-click a placed unit to return it to reserves. When ready, click <b>Commit</b>.",
TutorialDisplayMode.Modal);
unitPlacement.RequiredCompletedTutorials =
@@ -935,5 +938,52 @@ namespace Eagle0.Tutorial {
return sequence;
}
// ========== PRE-GAME TUTORIALS ==========
// These appear before the user enters a game (sign-in, lobby).
private static void RegisterLobbyTutorials(TutorialTriggerRegistry registry) {
// Sign-in tutorial - shown first time user sees the auth panel
var signIn = ScriptableObject.CreateInstance<TutorialSequence>();
signIn.SequenceId = "sign_in";
signIn.DisplayName = "Sign In";
signIn.IsOnboarding = false;
signIn.Steps = new List<TutorialStep> { new TutorialStep {
StepId = "sign_in_step",
Title = "Sign In to Play",
Description =
"Welcome to <b>Eagle0</b>!\n\n" +
"Sign in using the <b>same account</b> you used to create your display name on the website.\n\n" +
"Click one of the sign-in buttons below to continue.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
} };
registry.RegisterTutorial(signIn, "auth_panel_shown");
// Lobby introduction - shown first time user enters lobby
var lobbyIntro = ScriptableObject.CreateInstance<TutorialSequence>();
lobbyIntro.SequenceId = "lobby_intro";
lobbyIntro.DisplayName = "Welcome to the Lobby";
lobbyIntro.IsOnboarding = false;
lobbyIntro.Steps = new List<TutorialStep> { new TutorialStep {
StepId = "lobby_intro_step",
Title = "Welcome to Eagle0!",
Description =
"This is the game lobby. From here you can:\n\n" +
"• <b>Create a new game</b> to start a fresh campaign\n" +
"• <b>Join</b> a game waiting for players\n" +
"• <b>Continue</b> your existing games\n\n" +
"To get started, create a <b>1-player game</b> to learn the basics against AI opponents.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
} };
registry.RegisterTutorial(lobbyIntro, "lobby_entered");
}
}
}
@@ -1,33 +0,0 @@
<Properties StartupConfiguration="{4CE96269-4FA5-4F9D-BFBD-7440BA119166}|Default">
<MonoDevelop.Ide.ItemProperties.EagleInstaller PreferredExecutionTarget="MonoDevelop.Default" />
<MonoDevelop.Ide.Workbench ActiveDocument="EagleInstaller/EagleUpdater.cs">
<Files>
<File FileName="EagleInstaller/EagleUpdater.cs" Line="18" Column="60" />
<File FileName="EagleInstaller/Program.cs" Line="1" Column="14" />
</Files>
<Pads>
<Pad Id="ProjectPad">
<State name="__root__">
<Node name="EagleInstaller" expanded="True">
<Node name="EagleInstaller" expanded="True">
<Node name="EagleUpdater.cs" selected="True" />
</Node>
</Node>
</State>
</Pad>
<Pad Id="MonoDevelop.Debugger.WatchPad">
<State />
</Pad>
</Pads>
</MonoDevelop.Ide.Workbench>
<MonoDevelop.Ide.DebuggingService.PinnedWatches>
<Watch file="../../../../../unity/eagle0/Assets/Eagle/RemoteEagleClient.cs" line="37" column="0" endLine="0" endColumn="0" offsetX="-1" offsetY="-1" expression="grpcClient" liveUpdate="False" />
<Watch file="../../../../../unity/eagle0/Assets/Eagle/RemoteEagleClient.cs" line="37" column="0" endLine="0" endColumn="0" offsetX="-1" offsetY="-1" expression="grpcClient" liveUpdate="False" />
<Watch file="../../../../../unity/eagle0/Assets/Eagle/CommandSelectors/MarchCommandSelector.cs" line="89" column="0" endLine="0" endColumn="0" offsetX="-1" offsetY="-1" expression="SelectedOneProvinceCommand" liveUpdate="False" />
</MonoDevelop.Ide.DebuggingService.PinnedWatches>
<MonoDevelop.Ide.Workspace ActiveConfiguration="Debug" />
<MonoDevelop.Ide.DebuggingService.Breakpoints>
<BreakpointStore />
</MonoDevelop.Ide.DebuggingService.Breakpoints>
<MultiItemStartupConfigurations />
</Properties>
@@ -1,147 +0,0 @@
# Eagle0 Installer Credential System
The installer has been updated to use dynamic user credentials instead of hardcoded authentication. This improves security and provides a better user experience.
## Key Features
### 1. **Dynamic Credential Management**
- **No hardcoded passwords** in configuration files
- **User-prompted login** when credentials are needed
- **Secure credential storage** using Windows Data Protection API
- **Automatic credential sharing** with Unity client
### 2. **User Experience**
- **Saved credentials** are reused automatically
- **Login dialog** appears only when needed
- **Remember credentials** option for convenience
- **401 error handling** with automatic re-prompting
### 3. **Security Features**
- **Encrypted password storage** using DPAPI (user-scoped)
- **Registry storage** for username/server (non-sensitive)
- **Automatic credential clearing** on authentication failure
- **Unity client compatibility** for shared login state
## How It Works
### **First Run Flow**
1. User launches installer
2. No saved credentials found
3. Login dialog appears
4. User enters username/password/server
5. Credentials saved securely (if requested)
6. Installer proceeds with updates
### **Subsequent Runs**
1. User launches installer
2. Saved credentials loaded automatically
3. Installer proceeds with updates
4. No user interaction needed
### **Authentication Failure Flow**
1. Installer detects 401 Unauthorized response
2. Saved credentials cleared automatically
3. Login dialog appears with error message
4. User enters corrected credentials
5. Installer retries with new credentials
### **Changing Valid Credentials**
Users can change working credentials through multiple methods:
**Method 1: Interactive Prompt (Default)**
1. Launch installer normally
2. See message: "Using saved credentials for user: john@example.com"
3. Press 'C' within 3 seconds to change credentials
4. Login dialog appears with current credentials pre-filled
**Method 2: Command Line Flag**
```bash
EagleInstaller.exe --login
EagleInstaller.exe --change-credentials
```
**Method 3: Clear and Re-enter**
```bash
EagleInstaller.exe --clear-credentials
EagleInstaller.exe # Next run will prompt for new credentials
```
### **Credential Sharing with Unity Client**
- Launcher saves credentials in Unity PlayerPrefs format
- Unity client can access same username/password/server
- Seamless transition from launcher to game
## File Structure
### **New Files Added**
- `CredentialManager.cs` - Secure credential storage/retrieval
- `LoginDialog.cs` - Windows Forms login dialog
### **Modified Files**
- `Program.cs` - Integrated credential prompting and error handling
- `EagleUpdater.cs` - Dynamic authentication and 401 error detection
- `EagleInstaller.csproj` - Added Windows Forms support
- `configuration.txt` - Removed hardcoded credentials
### **Credential Storage Locations**
- **Username/Server**: `HKCU\SOFTWARE\Eagle0\Launcher`
- **Password**: `%LocalAppData%\eagle0\eagle0_credentials.dat` (encrypted)
- **Unity Sharing**: `HKCU\SOFTWARE\Unity\UnityEditor\CompanyName\ProductName`
## Configuration
The `configuration.txt` file now only contains non-sensitive settings:
```
manifest_name = eagle0WIN_manifest.txt
remote_manifest_prefix = assets/unity3d/
remote_asset_prefix = assets/unity3d/win/
```
Server URL, username, and password are now provided by the user through the login dialog.
## Command Line Options
The installer supports several command line flags for credential management:
```bash
EagleInstaller.exe # Normal operation
EagleInstaller.exe --help # Show help message
EagleInstaller.exe --login # Force credential dialog
EagleInstaller.exe --change-credentials # Same as --login
EagleInstaller.exe --clear-credentials # Clear saved credentials
```
During normal operation, users see a 3-second prompt to change credentials:
```
Using saved credentials for user: john@example.com
Press 'C' within 3 seconds to change credentials, or any other key to continue...
```
## Benefits
1. **Security**: No more plaintext passwords in config files
2. **Flexibility**: Users can connect to different servers/accounts
3. **User Experience**: Credentials remembered between sessions
4. **Easy Changes**: Multiple ways to update credentials
5. **Error Recovery**: Automatic handling of expired/invalid credentials
6. **Integration**: Shared credentials with Unity game client
7. **Self-Updating**: Maintains credential system through installer updates
## Technical Implementation
### **Two-Stage Approach**
The installer maintains both the two-stage installer update system AND the new credential system:
**Stage 1**: Check installer version (with authentication)
**Stage 2**: Update game files (with same credentials)
Both stages use the same credential system and handle 401 errors consistently.
### **Error Handling**
- `AuthenticationFailedException` for 401 responses
- Automatic credential retry loop
- Graceful fallback on credential prompt cancellation
- Backup/restore for failed credential saves
This system provides enterprise-grade credential management while maintaining the simple user experience of the original installer.
@@ -1,25 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29613.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EagleInstaller", "EagleInstaller\EagleInstaller.csproj", "{4CE96269-4FA5-4F9D-BFBD-7440BA119166}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {359E9771-2100-4864-B42B-14FA1A96A967}
EndGlobalSection
EndGlobal
@@ -1,3 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8a221793e4b13ae450dccb6e74f6985a273b27d67dca5c4bc2c2e56df33bfe7_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileStream_002EWindows_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ffc8f9bd3c0ac5ab6b9dabf421c55f1afeedd8abe1dfc288502292a552fb8d_003FFileStream_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
@@ -1,16 +0,0 @@
# load("@io_bazel_rules_dotnet//dotnet:defs.bzl", "core_binary")
#core_binary(
# name = "EagleInstaller.exe",
# srcs = [
# "EagleUpdater.cs",
# "Program.cs",
# ],
# deps = [
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.linq.dll",
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.net.dll",
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.console.dll",
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.diagnostics.process.dll",
# ],
# visibility = ["//visibility:public"],
#)
@@ -1,5 +0,0 @@
namespace EagleInstaller {
public class CredentialManager {
public const string ServerUrl = "https://assets.eagle0.net";
}
}
@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<PublishReadyToRun>true</PublishReadyToRun>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<UseWindowsForms>true</UseWindowsForms>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>eagle0.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- Ed25519 signature verification for manifest -->
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
</ItemGroup>
<ItemGroup>
<None Remove="AWSSDK.S3" />
</ItemGroup>
<ItemGroup>
<None Remove="configuration.txt" />
<EmbeddedResource Include="configuration.txt" />
</ItemGroup>
</Project>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<_LastSelectedProfileId>C:\Users\dan\OneDrive\Documents\GitHub\eagle0\clients\windows\EagleInstaller\EagleInstaller\Properties\PublishProfiles\FolderProfile.pubxml</_LastSelectedProfileId>
</PropertyGroup>
</Project>
@@ -1,583 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Threading;
namespace EagleInstaller {
internal class UpdaterFailureException
(string message) : Exception(message);
internal class ManifestSecurityException
(string message) : Exception(message);
public class InstallerUpdateInfo {
public bool InstallerNeedsUpdate { get; set; }
public string NewInstallerVersion { get; set; }
public string NewInstallerUrl { get; set; }
}
class EagleUpdater {
private static readonly string CurrentInstallerVersion = GetCurrentInstallerSHA();
private static readonly Dictionary<string, string> ConfigFileEntries = ReadConfiguration();
private static readonly string ManifestName = ConfigFileEntries["manifest_name"];
private static readonly string RemoteManifestName =
ConfigFileEntries["remote_manifest_prefix"] + ManifestName;
private static readonly string RemotePrefix = ConfigFileEntries["remote_asset_prefix"];
private const int DownloadSlots = 8;
private const int RetryCount = 3;
private readonly HttpClient _httpClient;
private static readonly SemaphoreSlim Pool = new(DownloadSlots, DownloadSlots);
private Uri ServiceUrl { get; set; }
private struct FetchAttempt {
public bool Success;
public KeyValuePair<string, string> Kvp;
}
private static string GetCurrentInstallerSHA() {
try {
string currentPath = Environment.ProcessPath ??
Path.Combine(AppContext.BaseDirectory, "EagleInstaller.exe");
using var sha256 = System.Security.Cryptography.SHA256.Create();
using var stream = File.OpenRead(currentPath);
var hashBytes = sha256.ComputeHash(stream);
return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
} catch (Exception) {
// If we can't calculate our own SHA, use a fallback
return "unknown";
}
}
private static Dictionary<string, string> ReadConfiguration() {
var assembly = System.Reflection.Assembly.GetExecutingAssembly();
using var stream =
assembly.GetManifestResourceStream("EagleInstaller.configuration.txt");
using var reader = new StreamReader(stream);
var configText = reader.ReadToEnd();
if (string.IsNullOrEmpty(configText)) { return new Dictionary<string, string>(); }
var dict = new Dictionary<string, string>();
using (StringReader sr = new StringReader(configText)) {
string line;
while ((line = sr.ReadLine()) != null) {
// Skip empty lines and comments
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) {
continue;
}
var idx = line.IndexOf(" = ");
if (idx > 0) {
var key = line.Substring(0, idx);
var value = line.Substring(idx + 3);
dict[key] = value;
}
}
}
return dict;
}
// Ed25519 public key for manifest signature verification (base64 encoded)
// Generated with: go run scripts/generate_manifest_keys.go
// This key should be updated when a new key pair is generated
private static readonly string ManifestPublicKeyB64 =
ConfigFileEntries.TryGetValue("manifest_public_key", out var key) ? key : null;
/// <summary>
/// Verifies the Ed25519 signature on a manifest.
/// Returns the manifest content (without signature line) if valid.
/// Throws ManifestSecurityException if signature is missing, invalid, or cannot be
/// verified.
/// </summary>
private static string VerifyManifestSignature(string manifestText) {
if (string.IsNullOrEmpty(manifestText)) {
throw new ManifestSecurityException("Manifest is empty");
}
// Require public key to be configured
if (string.IsNullOrEmpty(ManifestPublicKeyB64)) {
Logger.WriteError(
"SECURITY ERROR: No public key configured for manifest verification");
throw new ManifestSecurityException(
"No public key configured for manifest verification");
}
// Check for signature line at start: # signature=<base64>
var lines = manifestText.Split('\n');
if (lines.Length == 0 || !lines[0].TrimStart().StartsWith("# signature=")) {
Logger.WriteError("SECURITY ERROR: Manifest is not signed");
throw new ManifestSecurityException("Manifest is not signed");
}
// Extract signature
string signatureLine = lines[0];
string signatureB64 = signatureLine.Substring(signatureLine.IndexOf('=') + 1).Trim();
// Content is everything after the signature line
string content = string.Join('\n', lines.Skip(1));
try {
byte[] signatureBytes = Convert.FromBase64String(signatureB64);
byte[] publicKeyBytes = Convert.FromBase64String(ManifestPublicKeyB64);
byte[] contentBytes = System.Text.Encoding.UTF8.GetBytes(content);
// Verify using Ed25519
bool valid = VerifyEd25519Signature(publicKeyBytes, contentBytes, signatureBytes);
if (valid) {
Logger.WriteLine("Manifest signature verified successfully");
return content;
} else {
Logger.WriteError("SECURITY ERROR: Manifest signature is INVALID!");
Logger.WriteError("This could indicate a compromised or tampered manifest.");
throw new ManifestSecurityException("Manifest signature verification failed");
}
} catch (ManifestSecurityException) {
throw; // Re-throw security exceptions
} catch (Exception e) {
Logger.WriteError(
$"SECURITY ERROR: Failed to verify manifest signature: {e.Message}");
throw new ManifestSecurityException(
$"Manifest signature verification error: {e.Message}");
}
}
/// <summary>
/// Verifies an Ed25519 signature using the NSec library.
/// </summary>
private static bool
VerifyEd25519Signature(byte[] publicKeyBytes, byte[] data, byte[] signature) {
try {
var algorithm = NSec.Cryptography.SignatureAlgorithm.Ed25519;
var publicKey = NSec.Cryptography.PublicKey.Import(
algorithm,
publicKeyBytes,
NSec.Cryptography.KeyBlobFormat.RawPublicKey);
return algorithm.Verify(publicKey, data, signature);
} catch (Exception e) {
Logger.WriteError($"Ed25519 verification error: {e.Message}");
return false;
}
}
public EagleUpdater(string serverUrl) {
if (string.IsNullOrEmpty(serverUrl)) {
throw new ArgumentNullException(nameof(serverUrl));
}
ServiceUrl = new Uri(serverUrl);
_httpClient = new HttpClient();
}
private List<string> Discrepancies(
Dictionary<string, string> desired,
Dictionary<string, string> present) {
return desired
.Where(kvp => !present.ContainsKey(kvp.Key) ||
!present[kvp.Key].Equals(kvp.Value))
.Select(kvp => kvp.Key)
.ToList();
}
public async Task<InstallerUpdateInfo> CheckForInstallerUpdate() {
try {
Logger.WriteLine("Checking for installer updates...");
string manifestText = await RemoteManifestText();
// Parse installer version from manifest comments
var lines = manifestText.Split('\n');
var versionLine =
lines.FirstOrDefault(l => l.TrimStart().StartsWith("# installer_version="));
var urlLine =
lines.FirstOrDefault(l => l.TrimStart().StartsWith("# installer_url="));
if (versionLine != null && urlLine != null) {
var remoteVersion = versionLine.Split('=') [1].Trim();
var installerUrl = urlLine.Split('=') [1].Trim();
if (remoteVersion != CurrentInstallerVersion) {
Logger.WriteLine(
$"New installer version available: {remoteVersion} (current: {CurrentInstallerVersion})");
return new InstallerUpdateInfo {
InstallerNeedsUpdate = true,
NewInstallerVersion = remoteVersion,
NewInstallerUrl = installerUrl
};
}
}
Logger.WriteLine("Installer is up to date");
return new InstallerUpdateInfo { InstallerNeedsUpdate = false };
} catch (Exception e) {
Logger.WriteError($"Failed to check for installer updates: {e.Message}");
// If we can't check for updates, assume no update needed
return new InstallerUpdateInfo { InstallerNeedsUpdate = false };
}
}
public async Task<bool> MaybePerformUpdateWithRetries(string localPath) {
string existingManifestPath = Path.Combine(localPath, ManifestName);
Logger.UpdateStatus("Loading old manifest...");
string existingText = await LocalManifestText(existingManifestPath);
Logger.UpdateStatus("Fetching new manifest...");
string newText = await RemoteManifestText();
if (existingText != null && existingText.Equals(newText)) {
Logger.UpdateStatus("Files are up to date!");
return true;
}
var currentFiles = ShasFromText(existingText);
var desiredFiles = ShasFromText(newText);
List<string> discrepancies = Discrepancies(desiredFiles, currentFiles);
for (int retry = 0; retry < RetryCount; retry++) {
Logger.UpdateStatus($"Update attempt {retry + 1} of {RetryCount}...");
var newFiles = await AttemptUpdate(localPath, desiredFiles, currentFiles);
List<string> newDiscrepancies = Discrepancies(desiredFiles, newFiles);
if (newDiscrepancies.Count == 0) {
Logger.UpdateStatus("Update completed successfully!");
Logger.WriteLine("Writing downloaded manifest...");
File.WriteAllText(existingManifestPath, newText);
return true;
} else {
Logger.WriteLine("There are still some discrepancies.");
if (newDiscrepancies.Count == discrepancies.Count) {
Logger.WriteLine("We don't seem to be making progress.");
} else {
Logger.WriteLine("Some progress made. Remaining discrepancies:");
newDiscrepancies.ForEach(s => Logger.WriteLine($" {s}"));
discrepancies = newDiscrepancies;
}
Logger.WriteLine("Writing out the current state.");
File.WriteAllText(existingManifestPath, TextFromShas(newFiles));
currentFiles = newFiles;
}
}
// sadness
Logger.WriteError($"Exhausted {RetryCount} attempts. Update failed.");
return false;
}
public async Task<Dictionary<string, string>> AttemptUpdate(
string localPath,
Dictionary<string, string> desiredFiles,
Dictionary<string, string> existingFiles) {
try {
IEnumerable<KeyValuePair<string, string>> pathsToUpdate =
from newItem in desiredFiles where(
!existingFiles.ContainsKey(newItem.Key) ||
!existingFiles[newItem.Key].Equals(newItem.Value)) select newItem;
IEnumerable<Task<FetchAttempt>> updateTasks =
pathsToUpdate.Select(remotePathAndSha => {
Logger.WriteLine($"Downloading: {remotePathAndSha.Key}");
return FetchAndWriteOne(
remotePathAndSha.Key,
localPath,
remotePathAndSha.Value);
});
IEnumerable<string> pathsToDelete = from oldItem in existingFiles
where!desiredFiles.ContainsKey(oldItem.Key)
select oldItem.Key;
pathsToDelete.ToList().ForEach(path => {
var localDeletePath = WritePathFromRemotePath(path, localPath);
Logger.WriteLine($"Deleting: {localDeletePath}");
File.Delete(localDeletePath);
existingFiles.Remove(path);
});
IEnumerable<string> pathsOk =
from newItem in desiredFiles where(
existingFiles.ContainsKey(newItem.Key) &&
existingFiles[newItem.Key].Equals(newItem.Value))
select newItem.Key;
pathsOk.ToList().ForEach(path => Logger.WriteLine($"Up to date: {path}"));
var fetchAttemptResults = await Task.WhenAll(updateTasks);
// Add successful downloads to the existingFiles dictionary
fetchAttemptResults.Where(fa => fa.Success)
.Select(fa => fa.Kvp)
.ToList()
.ForEach(fa => existingFiles[fa.Key] = fa.Value);
if (fetchAttemptResults.ToList().TrueForAll(fa => fa.Success)) {
Logger.WriteLine("All downloads completed successfully.");
} else {
Logger.WriteError("Some file downloads failed!");
}
} catch (Exception e) { Logger.WriteError($"Unexpected exception: {e}"); }
return existingFiles;
}
static async Task<string> LocalManifestText(string existingManifestPath) {
if (!File.Exists(existingManifestPath)) return null;
return await File.ReadAllTextAsync(existingManifestPath);
}
async Task<string> RemoteManifestText() {
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, RemoteManifestName));
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
using StreamReader reader = new StreamReader(responseStream);
string rawManifest = await reader.ReadToEndAsync();
// Verify signature and extract content (strips signature line if present)
// Throws ManifestSecurityException if public key is configured and verification fails
return VerifyManifestSignature(rawManifest);
}
static Dictionary<String, String> ShasFromText(string text) {
if (text == null || text.Length == 0) { return new Dictionary<string, string>(); }
List<string> lines =
text.Split('\n').Where(str => str.Length > 0 && str[0] != '#').ToList();
return lines.Select(str => str.Split(" "))
.ToDictionary(strs => strs[1], strs => strs[0]);
}
static string TextFromShas(Dictionary<string, string> shas) {
string text = "";
foreach (var kvp in shas) { text += $"{kvp.Value} {kvp.Key}\n"; }
return text;
}
private static string WritePathFromRemotePath(string remotePath, string localDir) {
var components = remotePath.Split('/');
var writePath = localDir;
foreach (string component in components) {
writePath = Path.Combine(writePath, component);
}
return writePath;
}
async Task<FetchAttempt>
FetchAndWriteOne(string remotePath, string localDir, string expectedSha) {
string writePath = WritePathFromRemotePath(remotePath, localDir);
string tempPath = writePath + ".downloading";
Directory.CreateDirectory(Path.GetDirectoryName(writePath));
await Pool.WaitAsync();
try {
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, RemotePrefix + remotePath));
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
// Stream directly to disk while computing SHA256 incrementally
using var sha = System.Security.Cryptography.SHA256.Create();
await using (FileStream fileStream = File.Create(tempPath)) {
var buffer = new byte[81920]; // 80KB buffer
int bytesRead;
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) >
0) {
// Write to disk
await fileStream.WriteAsync(buffer, 0, bytesRead);
// Update hash incrementally
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
}
}
// Finalize hash computation
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
string computedSha =
BitConverter.ToString(sha.Hash).Replace("-", string.Empty).ToLower();
if (computedSha.Equals(expectedSha)) {
// SHA matches - rename temp file to final location
if (File.Exists(writePath)) { File.Delete(writePath); }
File.Move(tempPath, writePath);
Logger.WriteLine($"✓ Verified and saved: {remotePath}");
return new FetchAttempt {
Success = true,
Kvp = new KeyValuePair<String, String>(remotePath, computedSha)
};
} else {
// SHA mismatch - delete temp file
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"✗ SHA mismatch for {remotePath}");
return new FetchAttempt() { Success = false };
}
} catch (TaskCanceledException e) {
// Clean up temp file on failure
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Download cancelled: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (HttpRequestException e) {
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Network error: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (Exception e) {
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Download failed: {e.Message}");
return new FetchAttempt() { Success = false };
} finally { Pool.Release(); }
}
public async Task<bool> DownloadAndLaunchNewInstaller(
string installerUrl,
string expectedSha) {
try {
Logger.UpdateStatus("Downloading new installer...");
// Create temp directory for new installer
string tempDir = Path.Combine(Path.GetTempPath(), "eagle0_installer_update");
Directory.CreateDirectory(tempDir);
string newInstallerPath = Path.Combine(tempDir, "EagleInstaller_new.exe");
// Download new installer with progress tracking
{
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, installerUrl));
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = File.Create(newInstallerPath);
// Download with progress feedback
var buffer = new byte[8192];
long totalBytesRead = 0;
int bytesRead;
int lastProgressDots = 0;
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) >
0) {
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalBytesRead += bytesRead;
// Show progress as dots or percentage
if (totalBytes.HasValue) {
int progressPercent = (int)((totalBytesRead * 100) / totalBytes.Value);
Logger.UpdateStatus($"Downloading new installer... {progressPercent}%");
} else {
// Show dots if we don't know total size
int currentDots = (int)(totalBytesRead / 10240); // One dot per 10KB
if (currentDots > lastProgressDots) {
lastProgressDots = currentDots;
string dots = new string(
'.',
Math.Min(currentDots % 20, 20)); // Cycle dots every 20
Logger.UpdateStatus($"Downloading new installer{dots}");
}
}
}
}
// Verify SHA256 before launching
Logger.UpdateStatus("Verifying installer integrity...");
string computedSha;
using (var sha256 = System.Security.Cryptography.SHA256.Create()) using (
var stream = File.OpenRead(newInstallerPath)) {
var hashBytes = sha256.ComputeHash(stream);
computedSha =
BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
}
if (!computedSha.Equals(expectedSha, StringComparison.OrdinalIgnoreCase)) {
Logger.WriteError($"SECURITY ERROR: Installer SHA mismatch!");
Logger.WriteError($" Expected: {expectedSha}");
Logger.WriteError($" Got: {computedSha}");
// Delete the corrupted/tampered file
try {
File.Delete(newInstallerPath);
} catch {}
return false;
}
Logger.WriteLine($"✓ Installer verified: SHA256 matches");
// Remove Windows "Mark of the Web" Zone.Identifier to prevent SmartScreen blocking
// When launched programmatically, Windows blocks unsigned executables downloaded
// from the internet. Removing the Zone.Identifier alternate data stream fixes this.
// Note: File.Exists() doesn't work with ADS, so we just try to delete
// unconditionally.
try {
string zoneIdPath = newInstallerPath + ":Zone.Identifier";
File.Delete(zoneIdPath);
Logger.WriteLine("Removed Zone.Identifier from downloaded installer");
} catch (Exception) {
// Ignore errors - may not have Zone.Identifier or access denied
}
// Wait a moment to ensure file handles are released
await Task.Delay(500);
Logger.WriteLine($"New installer downloaded successfully");
// Get current installer path
string currentInstallerPath =
Environment.ProcessPath ??
Path.Combine(AppContext.BaseDirectory, "EagleInstaller.exe");
// Launch new installer with replacement arguments
var processInfo = new System.Diagnostics.ProcessStartInfo {
FileName = newInstallerPath,
Arguments = $"--replace-installer \"{currentInstallerPath}\"",
UseShellExecute = true
};
Logger.WriteLine($"About to launch: {newInstallerPath}");
Logger.WriteLine($"With arguments: {processInfo.Arguments}");
Logger.WriteLine($"File exists: {File.Exists(newInstallerPath)}");
Logger.UpdateStatus("Launching new installer...");
var process = System.Diagnostics.Process.Start(processInfo);
if (process == null) {
Logger.WriteError("Failed to start new installer process");
return false;
}
Logger.WriteLine($"New installer process started with ID: {process.Id}");
// Wait a moment to see if the process exits immediately (crash)
await Task.Delay(500);
if (process.HasExited) {
Logger.WriteError(
$"New installer process exited immediately with code: {process.ExitCode}");
return false;
}
Logger.WriteLine("New installer appears to be running successfully");
return true;
} catch (Exception e) {
Logger.WriteError($"Failed to download or launch new installer: {e}");
return false;
}
}
}
}
@@ -1,29 +0,0 @@
using System;
namespace EagleInstaller {
public static class Logger {
private static MainForm _mainForm;
public static void Initialize(MainForm mainForm) { _mainForm = mainForm; }
public static void WriteLine(string message) {
// Write to console (for when running from command line)
Console.WriteLine(message);
// Also write to GUI if available
if (_mainForm != null) { _mainForm.AppendProgress(message); }
}
public static void UpdateStatus(string status) {
// Write to console
Console.WriteLine($"STATUS: {status}");
// Update GUI status if available
if (_mainForm != null) { _mainForm.UpdateStatus(status); }
}
public static void WriteError(string message) { WriteLine($"ERROR: {message}"); }
public static void WriteWarning(string message) { WriteLine($"WARNING: {message}"); }
}
}
@@ -1,97 +0,0 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace EagleInstaller {
public partial class MainForm : Form {
private Panel _progressPanel;
private TextBox _progressTextBox;
private Label _statusLabel;
private Button _exitButton;
public string ServerUrl => CredentialManager.ServerUrl;
public MainForm() { InitializeComponent(); }
private void InitializeComponent() {
// Form properties
this.Text = "Eagle0 Installer";
this.Size = new Size(600, 450);
this.StartPosition = FormStartPosition.CenterScreen;
this.FormBorderStyle = FormBorderStyle.FixedSingle;
this.MaximizeBox = false;
this.MinimizeBox = true;
// Create progress panel
CreateProgressPanel();
// Add panel to form
this.Controls.Add(_progressPanel);
}
private void CreateProgressPanel() {
_progressPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10) };
// Title and status
var titleLabel = new Label {
Text = "Eagle0 Installer",
Font = new Font("Microsoft Sans Serif", 14F, FontStyle.Bold),
Location = new Point(10, 10),
Size = new Size(200, 30)
};
_statusLabel = new Label {
Text = "Initializing...",
Location = new Point(10, 45),
Size = new Size(560, 23),
ForeColor = Color.Blue
};
// Progress text box with scrollbar
_progressTextBox = new TextBox {
Location = new Point(10, 75),
Size = new Size(560, 310),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
Font = new Font("Consolas", 9F),
BackColor = Color.Black,
ForeColor = Color.LightGreen,
TabIndex = 0
};
// Exit button
_exitButton = new Button {
Text = "Exit",
Location = new Point(490, 395),
Size = new Size(80, 30)
};
_exitButton.Click += ExitButton_Click;
// Add controls to progress panel
_progressPanel.Controls.AddRange(
new Control[] { titleLabel, _statusLabel, _progressTextBox, _exitButton });
}
public void UpdateStatus(string status) {
if (_statusLabel.InvokeRequired) {
_statusLabel.Invoke(new Action<string>(UpdateStatus), status);
return;
}
_statusLabel.Text = status;
}
public void AppendProgress(string message) {
if (_progressTextBox.InvokeRequired) {
_progressTextBox.Invoke(new Action<string>(AppendProgress), message);
return;
}
_progressTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
_progressTextBox.SelectionStart = _progressTextBox.Text.Length;
_progressTextBox.ScrollToCaret();
}
private void ExitButton_Click(object sender, EventArgs e) { this.Close(); }
}
}
@@ -1,297 +0,0 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace EagleInstaller {
class Program {
private static readonly string FileLocation = Path.Combine(
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 {
string tempDir = Path.Combine(Path.GetTempPath(), "eagle0_installer_update");
if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, true); }
} catch (Exception) {
// Ignore cleanup failures - not critical
}
}
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();
Application.SetCompatibleTextRenderingDefault(false);
// Clean up any leftover temp installer files from previous updates
CleanupTempInstallerFiles();
// Handle installer replacement if launched with --replace-installer flag
if (args.Length > 0 && args[0] == "--replace-installer") {
await HandleInstallerReplacement(args);
return;
}
bool showHelp = args.Length > 0 && (args[0] == "--help" || args[0] == "-h");
// Show help if requested
if (showHelp) {
ShowHelp();
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);
// Show the form and ensure handle is created
mainForm.Show();
mainForm.CreateControl();
// Start the installer logic in the background
var installerTask = RunInstallerLogic(mainForm, invitationCode);
// Run the application with the main form
Application.Run(mainForm);
// Wait for installer logic to complete
await installerTask;
}
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
var installerUpdateInfo = await updater.CheckForInstallerUpdate();
if (installerUpdateInfo.InstallerNeedsUpdate) {
Logger.UpdateStatus("Installer update required...");
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
installerUpdateInfo.NewInstallerUrl,
installerUpdateInfo.NewInstallerVersion);
if (downloadSuccess) {
Logger.UpdateStatus("New installer launched. Exiting current version.");
// Wait a moment to ensure new installer starts before we exit
await Task.Delay(1000);
// Exit the application directly
Application.Exit();
return; // Exit this version
}
Logger.WriteError(
"Failed to download new installer, continuing with current version...");
}
// Stage 2: Proceed with normal game update
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);
// Exit the installer after launching the game
Application.Exit();
} else {
Logger.WriteError("Update failed!");
}
} catch (Exception ex) { Logger.WriteError($"Unexpected error: {ex.Message}"); }
}
private static async Task HandleInstallerReplacement(string[] args) {
if (args.Length < 2) {
MessageBox.Show(
"Error: --replace-installer requires path to old installer",
"Installer Update Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
return;
}
string oldInstallerPath = args[1];
string currentInstallerPath =
Environment.ProcessPath ??
Path.Combine(AppContext.BaseDirectory, "EagleInstaller.exe");
// Show a progress form for the replacement process
var progressForm = new Form {
Text = "Updating Installer",
Size = new System.Drawing.Size(400, 150),
FormBorderStyle = FormBorderStyle.FixedDialog,
MaximizeBox = false,
MinimizeBox = false,
StartPosition = FormStartPosition.CenterScreen
};
var label = new Label {
Text = "Updating installer, please wait...",
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter
};
progressForm.Controls.Add(label);
progressForm.Show();
Application.DoEvents();
try {
// Wait for the old process to exit - check if file is still locked
label.Text = "Waiting for previous installer to close...";
Application.DoEvents();
// Wait up to 30 seconds for the old installer file to be unlocked
int maxWaitSeconds = 30;
bool oldProcessExited = false;
for (int i = 0; i < maxWaitSeconds && !oldProcessExited; i++) {
try {
// Try to open the old installer file for writing
// If this succeeds, the old process has released the file
using (var fs = File.Open(
oldInstallerPath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None)) {
// File is not locked, old process has exited
oldProcessExited = true;
label.Text = "Previous installer has exited, proceeding...";
Application.DoEvents();
}
} catch (IOException) {
// File is still locked by old process, wait a bit more
label.Text = $"Waiting for previous installer to close... ({i + 1}s)";
Application.DoEvents();
await Task.Delay(1000);
}
}
if (!oldProcessExited) {
throw new Exception(
$"Old installer process did not exit after {maxWaitSeconds} seconds");
}
// Backup old installer
string backupPath = oldInstallerPath + ".backup";
label.Text = "Backing up current installer...";
Application.DoEvents();
if (File.Exists(oldInstallerPath)) {
// Remove existing backup if it exists
if (File.Exists(backupPath)) { File.Delete(backupPath); }
File.Move(oldInstallerPath, backupPath);
}
Application.DoEvents();
// Copy new installer to old location
label.Text = "Installing new version...";
Application.DoEvents();
File.Copy(currentInstallerPath, oldInstallerPath, true);
Application.DoEvents();
label.Text = "Launching updated installer...";
Application.DoEvents();
// Launch the newly installed version
var processInfo = new System.Diagnostics.ProcessStartInfo {
FileName = oldInstallerPath,
UseShellExecute = true
};
System.Diagnostics.Process.Start(processInfo);
label.Text = "Launching new installer...";
Application.DoEvents();
// Exit immediately after launching - no delay needed
Environment.Exit(0);
} catch (Exception e) {
MessageBox.Show(
$"Failed to replace installer: {e.Message}",
"Installer Update Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
// Restore backup if replacement failed
string backupPath = oldInstallerPath + ".backup";
if (File.Exists(backupPath) && !File.Exists(oldInstallerPath)) {
try {
File.Move(backupPath, oldInstallerPath);
MessageBox.Show(
"Installer backup restored.",
"Recovery",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
} catch (Exception restoreEx) {
MessageBox.Show(
$"Failed to restore backup: {restoreEx.Message}",
"Recovery Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
} finally { progressForm?.Close(); }
}
private static void ShowHelp() {
Console.WriteLine("Eagle0 Installer");
Console.WriteLine();
Console.WriteLine("USAGE:");
Console.WriteLine(" EagleInstaller.exe [OPTIONS]");
Console.WriteLine();
Console.WriteLine("OPTIONS:");
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.");
Console.WriteLine(" Downloads game files from assets.eagle0.net CDN.");
}
}
}
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<WebPublishMethod>FileSystem</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
<publishUrl>../../../../../../../Documents/winbuild</publishUrl>
<DeleteExistingFiles>false</DeleteExistingFiles>
<TargetFramework>netcoreapp3.1</TargetFramework>
<SelfContained>false</SelfContained>
<_IsPortable>true</_IsPortable>
</PropertyGroup>
</Project>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<WebPublishMethod>FileSystem</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
<publishUrl>/Volumes/Media/winbuild</publishUrl>
<DeleteExistingFiles>false</DeleteExistingFiles>
<TargetFramework>netcoreapp3.1</TargetFramework>
<SelfContained>false</SelfContained>
<_IsPortable>true</_IsPortable>
</PropertyGroup>
</Project>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<WebPublishMethod>FileSystem</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
<publishUrl>../../installer</publishUrl>
<DeleteExistingFiles>false</DeleteExistingFiles>
<TargetFramework>netcoreapp3.1</TargetFramework>
<SelfContained>false</SelfContained>
<_IsPortable>true</_IsPortable>
</PropertyGroup>
</Project>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<WebPublishMethod>FileSystem</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
<publishUrl>../../installer</publishUrl>
<DeleteExistingFiles>false</DeleteExistingFiles>
<TargetFramework>netcoreapp3.1</TargetFramework>
<SelfContained>false</SelfContained>
<_IsPortable>true</_IsPortable>
</PropertyGroup>
</Project>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<WebPublishMethod>FileSystem</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
<publishUrl>../../installer</publishUrl>
<DeleteExistingFiles>false</DeleteExistingFiles>
<TargetFramework>netcoreapp3.1</TargetFramework>
<SelfContained>false</SelfContained>
<_IsPortable>true</_IsPortable>
</PropertyGroup>
</Project>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<PublishProtocol>FileSystem</PublishProtocol>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<TargetFramework>netcoreapp3.1</TargetFramework>
<PublishDir>C:\Users\dan\OneDrive\Desktop\updater</PublishDir>
</PropertyGroup>
</Project>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
</Project>
@@ -1,8 +0,0 @@
manifest_name = eagle0_manifest.txt
remote_manifest_prefix = installer/v2/
remote_asset_prefix = unity3d/win/
# Ed25519 public key for manifest signature verification (base64)
# Generate key pair with: go run scripts/generate_manifest_keys.go
# Copy the public key here after generating
# manifest_public_key = YOUR_PUBLIC_KEY_HERE
@@ -1,129 +0,0 @@
# Eagle0 Installer GUI Update
The installer has been transformed from a console application to a modern Windows Forms GUI application that displays file download information in a scrollable window.
## Key Changes
### **New GUI Components**
**1. MainForm.cs**
- **Dual-panel design**: Login view and Progress view
- **Login Panel**: Username, password, server URL, remember credentials checkbox
- **Progress Panel**: Status label + scrollable text box with file download details
- **Terminal-style output**: Black background, green text, console font
- **Timestamped messages**: Each log entry shows `[HH:mm:ss] message`
**2. Logger.cs**
- **Unified logging system**: Outputs to both console AND GUI
- **Thread-safe**: Uses Invoke for cross-thread GUI updates
- **Multiple log levels**: WriteLine, UpdateStatus, WriteError, WriteWarning
- **Auto-scrolling**: Progress text box automatically scrolls to show latest messages
### **User Experience Flow**
**1. Application Startup**
- Windows Forms application opens with professional login dialog
- Saved credentials auto-populated (if available)
- "Remember credentials" checkbox for convenience
**2. Login Process**
- User enters/confirms credentials
- Click "Login" button to proceed
- Form switches to progress view
**3. Progress Display**
- **Status line**: Shows current operation (e.g., "Downloading files...")
- **Scrollable log**: Shows detailed file-by-file progress
- **Color-coded messages**:
- ✓ Green checkmarks for successful downloads
- ✗ Red X marks for errors
- Blue status updates
- **Change Credentials button**: Allows switching back to login
**4. File Download Information**
Now appears in the GUI progress window:
```
[14:32:15] Downloading: game/eagle0.exe
[14:32:16] ✓ Verified and saved: game/eagle0.exe
[14:32:16] Downloading: game/assets/textures.bundle
[14:32:18] ✓ Verified and saved: game/assets/textures.bundle
[14:32:18] Up to date: game/data.unity3d
[14:32:18] All downloads completed successfully.
```
### **Technical Implementation**
**Unified Logging System:**
```csharp
// Both console and GUI output
Logger.WriteLine("Downloading: game/eagle0.exe");
Logger.UpdateStatus("Update completed successfully!");
Logger.WriteError("Network connection failed");
```
**Thread-Safe GUI Updates:**
```csharp
public void AppendProgress(string message) {
if (progressTextBox.InvokeRequired) {
progressTextBox.Invoke(new Action<string>(AppendProgress), message);
return;
}
progressTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
progressTextBox.ScrollToCaret();
}
```
**Dual Output Support:**
- **Console mode**: When run from command line, still shows console output
- **GUI mode**: When double-clicked, shows Windows Forms interface
- **Both simultaneously**: Console + GUI for debugging
### **Credential Management Integration**
All existing credential features work with the new GUI:
- **Command line flags**: `--login`, `--clear-credentials`, `--help`
- **Saved credentials**: Auto-populate form fields
- **Authentication errors**: Show login form with error message
- **Unity client sharing**: Credentials still saved for game client
### **File Structure Changes**
**New Files:**
- `MainForm.cs` - Main Windows Forms interface
- `Logger.cs` - Unified console/GUI logging system
- `GUI_UPDATE.md` - This documentation
**Modified Files:**
- `Program.cs` - GUI application lifecycle management
- `EagleUpdater.cs` - All Console.WriteLine → Logger calls
- `EagleInstaller.csproj` - Added Windows Forms support
### **Benefits**
1. **Professional Appearance**: Modern Windows Forms interface
2. **Better User Experience**: Clear progress indication with scrollable log
3. **File Download Visibility**: Users can see exactly which files are being processed
4. **Error Transparency**: Color-coded success/failure indicators
5. **Credential Management**: Integrated login with remember option
6. **Backwards Compatibility**: Command line flags still work
7. **Debug Friendly**: Both console and GUI output available
### **Example Output in GUI**
```
STATUS: Fetching new manifest...
[14:30:42] Checking for installer updates...
[14:30:43] Installer is up to date
[14:30:43] Update attempt 1 of 3...
[14:30:43] Downloading: game/eagle0.exe
[14:30:45] ✓ Verified and saved: game/eagle0.exe
[14:30:45] Downloading: game/assets/music.bundle
[14:30:47] ✓ Verified and saved: game/assets/music.bundle
[14:30:47] Up to date: game/data.unity3d
[14:30:47] Up to date: game/config.json
[14:30:47] All downloads completed successfully.
STATUS: Update completed successfully!
[14:30:48] Launching Eagle0...
```
The installer now provides a professional, user-friendly interface while maintaining all existing functionality including the two-stage installer updates and comprehensive credential management system.
@@ -1,6 +0,0 @@
# installer_version=1.1.0
# installer_url=assets/installer/EagleInstaller.exe
a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456 game/eagle0.exe
b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567 game/data.unity3d
c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890 game/assets/textures.bundle
d4e5f6789012345678901234567890abcdef1234567890abcdef12345678901a game/assets/audio.bundle
@@ -1159,13 +1159,20 @@ func handleGameHistoryAPI(w http.ResponseWriter, r *http.Request) {
// Settings data structures
type SettingInfo struct {
Name string `json:"name"`
SettingType string `json:"setting_type"`
CurrentValue string `json:"current_value"`
DefaultValue string `json:"default_value"`
IsModified bool `json:"is_modified"`
Name string `json:"name"`
SettingType string `json:"setting_type"`
CurrentValue string `json:"current_value"`
DefaultValue string `json:"default_value"`
IsModified bool `json:"is_modified"`
DropdownOptions []string `json:"dropdown_options,omitempty"`
}
// LLM model dropdown options (recommended models first)
var llmProviderOptions = []string{"gemini", "openai", "claude"}
var openAiModelOptions = []string{"gpt-4.1-mini", "gpt-5-mini", "gpt-5.1", "gpt-5.2", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"}
var claudeModelOptions = []string{"claude-3-5-haiku-20241022", "claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20241022"}
var geminiModelOptions = []string{"gemini-2.5-flash-lite", "gemini-2.5-flash", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-3-pro-preview"}
type SettingsPageData struct {
Title string
Settings []SettingInfo
@@ -1185,6 +1192,42 @@ type SettingUpdateResult struct {
Error string `json:"error,omitempty"`
}
// getDropdownOptionsForSetting returns dropdown options for a setting if it has any
func getDropdownOptionsForSetting(name string) []string {
switch name {
case "LlmProvider":
return llmProviderOptions
case "OpenAiModelName":
return openAiModelOptions
case "ClaudeModelName":
return claudeModelOptions
case "GeminiModelName":
return geminiModelOptions
}
return nil
}
// generateSettingInputHTML generates the HTML for a setting's input (text input or dropdown)
func generateSettingInputHTML(name, currentValue string) string {
options := getDropdownOptionsForSetting(name)
if options != nil {
// Generate dropdown
var sb strings.Builder
sb.WriteString(`<select name="value" class="setting-input setting-select">`)
for _, opt := range options {
if opt == currentValue {
sb.WriteString(fmt.Sprintf(`<option value="%s" selected>%s</option>`, opt, opt))
} else {
sb.WriteString(fmt.Sprintf(`<option value="%s">%s</option>`, opt, opt))
}
}
sb.WriteString(`</select>`)
return sb.String()
}
// Generate text input
return fmt.Sprintf(`<input type="text" name="value" value="%s" class="setting-input">`, currentValue)
}
func fetchSettings(ctx context.Context, filter string) ([]SettingInfo, error) {
resp, err := gameAdminClient.GetSettings(ctx, &gameadminpb.GetSettingsRequest{
Filter: filter,
@@ -1195,13 +1238,23 @@ func fetchSettings(ctx context.Context, filter string) ([]SettingInfo, error) {
settings := make([]SettingInfo, len(resp.Settings))
for i, s := range resp.Settings {
settings[i] = SettingInfo{
setting := SettingInfo{
Name: s.Name,
SettingType: s.SettingType,
CurrentValue: s.CurrentValue,
DefaultValue: s.DefaultValue,
IsModified: s.CurrentValue != s.DefaultValue,
}
// Add dropdown options for LLM settings
switch s.Name {
case "LlmProvider":
setting.DropdownOptions = llmProviderOptions
case "OpenAiModelName":
setting.DropdownOptions = openAiModelOptions
case "ClaudeModelName":
setting.DropdownOptions = claudeModelOptions
}
settings[i] = setting
}
return settings, nil
}
@@ -1350,18 +1403,19 @@ func handleSettingUpdate(w http.ResponseWriter, r *http.Request) {
if updated.IsModified {
modifiedClass = " modified"
}
inputHTML := generateSettingInputHTML(updated.Name, updated.CurrentValue)
fmt.Fprintf(w, `<tr class="setting-row%s" id="setting-%s">
<td class="setting-name">%s</td>
<td class="setting-type">%s</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-%s" hx-swap="outerHTML">
<input type="hidden" name="name" value="%s">
<input type="text" name="value" value="%s" class="setting-input">
%s
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">%s</td>
</tr>`, modifiedClass, updated.Name, updated.Name, updated.SettingType, updated.Name, updated.Name, updated.CurrentValue, updated.DefaultValue)
</tr>`, modifiedClass, updated.Name, updated.Name, updated.SettingType, updated.Name, updated.Name, inputHTML, updated.DefaultValue)
}
// Player management handlers
@@ -287,6 +287,11 @@ nav .brand {
border-width: 1px;
}
.setting-input.setting-select {
width: 220px;
cursor: pointer;
}
.setting-default {
font-size: 0.8rem;
color: var(--pico-muted-color);
@@ -98,11 +98,17 @@
}
.login-buttons {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.login-buttons {
grid-template-columns: 1fr;
}
}
.login-btn {
display: flex;
align-items: center;
@@ -31,7 +31,16 @@
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
{{if .DropdownOptions}}
<select name="value" class="setting-input setting-select">
{{$current := .CurrentValue}}
{{range .DropdownOptions}}
<option value="{{.}}"{{if eq . $current}} selected{{end}}>{{.}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
{{end}}
<button type="submit" class="btn-small">Save</button>
</form>
</td>
@@ -5,7 +5,16 @@
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
{{if .DropdownOptions}}
<select name="value" class="setting-input setting-select">
{{$current := .CurrentValue}}
{{range .DropdownOptions}}
<option value="{{.}}"{{if eq . $current}} selected{{end}}>{{.}}</option>
{{end}}
</select>
{{else}}
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
{{end}}
<button type="submit" class="btn-small">Save</button>
</form>
</td>
@@ -45,7 +45,7 @@ const oauthSessionExpiration = 10 * time.Minute
func NewInvitationHTTPHandler(invitationService *InvitationService, userService *UserService, oauthService *OAuthService) *InvitationHTTPHandler {
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
installerURL = "https://assets.eagle0.net/installer/Eagle0.exe"
}
macInstallerURL := os.Getenv("MAC_INSTALLER_URL")
if macInstallerURL == "" {
@@ -562,7 +562,7 @@ echo.
echo Downloading Eagle0 installer...
echo.
curl -L -o "%%TEMP%%\EagleInstaller.exe" "%s"
curl -L -o "%%TEMP%%\Eagle0.exe" "%s"
if %%ERRORLEVEL%% neq 0 (
echo.
echo Failed to download installer. Please check your internet connection.
@@ -575,7 +575,7 @@ if %%ERRORLEVEL%% neq 0 (
echo.
echo Starting installer...
echo.
start "" "%%TEMP%%\EagleInstaller.exe" --code=%s
start "" "%%TEMP%%\Eagle0.exe" --code=%s
echo.
echo The installer should now be running.
@@ -731,11 +731,16 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
text-align: center;
}
.oauth-buttons {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 30px 0;
}
@media (max-width: 480px) {
.oauth-buttons {
grid-template-columns: 1fr;
}
}
.oauth-button {
display: flex;
align-items: center;
@@ -1187,7 +1192,7 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
<h3>Installation Instructions:</h3>
<ol>
<li>Click "Download for Windows" to download the installer</li>
<li>Run <strong>EagleInstaller.exe</strong></li>
<li>Run <strong>Eagle0.exe</strong></li>
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
<li>Launch Eagle0 and sign in with the same account you just used</li>
</ol>
@@ -52,7 +52,7 @@ func NewEmailService() *EmailService {
fromName = "Eagle0 Game"
}
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
installerURL = "https://assets.eagle0.net/installer/Eagle0.exe"
}
if serverBaseURL == "" {
serverBaseURL = "https://prod.eagle0.net"
@@ -11,7 +11,7 @@ import (
)
var bucketName = "eagle0-assets"
var defaultInstallerKey = "installer/EagleInstaller.exe"
var defaultInstallerKey = "installer/Eagle0.exe"
var versionKey = "installer/version.txt"
func sha(filePath string) (string, error) {
@@ -68,7 +68,7 @@ func main() {
log.Printf("Successfully uploaded installer to s3://%s/%s", bucketName, installerKey)
// Create a version/metadata file with the SHA256
versionContent := fmt.Sprintf("sha256=%s\nversion=1.0.0\nurl=installer/EagleInstaller.exe", installerSha)
versionContent := fmt.Sprintf("sha256=%s\nversion=1.0.0\nurl=%s", installerSha, installerKey)
// Write version info to temp file
tempVersionFile := filepath.Join(os.TempDir(), "installer_version.txt")
@@ -28,6 +28,12 @@ func main() {
capitalizedSettingType := capitalized(settingType)
// For String types, wrap the value in quotes
formattedValue := settingValue
if strings.ToLower(settingType) == "string" {
formattedValue = fmt.Sprintf("\"%s\"", settingValue)
}
fmt.Printf(`// Generated file, do not edit!
package net.eagle0.eagle.library.settings
@@ -35,5 +41,5 @@ package net.eagle0.eagle.library.settings
import net.eagle0.eagle.library.settings.base.%sSetting
object %s extends %sSetting(%s)
`, capitalizedSettingType, settingName, capitalizedSettingType, settingValue)
`, capitalizedSettingType, settingName, capitalizedSettingType, formattedValue)
}
@@ -76,7 +76,7 @@ func generateSettingsLoader(settings []Setting) string {
sb.WriteString("package net.eagle0.eagle.library.settings.loaders\n\n")
sb.WriteString("import net.eagle0.common.TsvUtils\n")
sb.WriteString("import net.eagle0.eagle.library.settings.base.{DoubleSetting, IntSetting}\n")
sb.WriteString("import net.eagle0.eagle.library.settings.base.{DoubleSetting, IntSetting, StringSetting}\n")
sb.WriteString("// Auto-generated imports for all settings\n")
sb.WriteString("import net.eagle0.eagle.library.settings._\n\n")
sb.WriteString("import java.net.URL\n\n")
@@ -111,12 +111,16 @@ func generateSettingsLoader(settings []Setting) string {
sb.WriteString(" def getAllSettings: Vector[SettingInfo] = Vector(\n")
for i, setting := range settings {
if setting.Type == "Int" {
switch setting.Type {
case "Int":
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Int\", %s.intValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
} else {
case "Double":
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Double\", %s.doubleValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
case "String":
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"String\", %s.stringValue, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
}
if i < len(settings)-1 {
sb.WriteString(",")
@@ -142,6 +146,9 @@ func generateSettingsLoader(settings []Setting) string {
case "double" =>
obj.asInstanceOf[DoubleSetting].setDoubleValue(setting.value.toDouble)
true
case "string" =>
obj.asInstanceOf[StringSetting].setStringValue(setting.value)
true
case typeName =>
throw new Exception(s"Invalid setting type $typeName")
}
@@ -182,6 +189,8 @@ func generateSettingsLoader(settings []Setting) string {
intSetting.setIntValue(value.toInt)
case doubleSetting: DoubleSetting =>
doubleSetting.setDoubleValue(value.toDouble)
case stringSetting: StringSetting =>
stringSetting.setStringValue(value)
case _ =>
throw NoSuchSettingException(key)
}
@@ -1,21 +1,29 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
# Pure Go library (uses stub for WebView)
go_library(
name = "installer_lib",
srcs = [
"config.go",
"main.go",
"ui.go",
"ui_interface.go",
"ui_webview_stub.go",
"ui_webview_windows.go",
"updater.go",
],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/clients/win/installer",
visibility = ["//visibility:private"],
deps = select({
"@io_bazel_rules_go//go/platform:windows": [
"@com_github_webview_webview_go//:webview_go",
],
"//conditions:default": [],
}),
)
# Windows x86_64 installer binary
# Windows x86_64 installer binary (pure Go, console-only)
# Build with: bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64
# The manifest public key is injected at build time via x_defs
# The resource_windows.syso file embeds the application icon and version info
go_binary(
name = "eagle_installer_windows_amd64",
srcs = ["resource_windows.syso"],
@@ -26,17 +34,161 @@ go_binary(
pure = "on",
visibility = ["//visibility:public"],
x_defs = {
"main.manifestPublicKey": "{MANIFEST_PUBLIC_KEY}",
"main.manifestPublicKey": "{STABLE_MANIFEST_PUBLIC_KEY}",
},
)
# Native binary for testing on current platform
# Windows x86_64 installer with WebView GUI
# Build with: bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview
# This uses CGO cross-compilation with llvm-mingw toolchain
genrule(
name = "eagle_installer_windows_amd64_webview",
srcs = [
"config.go",
"main.go",
"ui.go",
"ui_interface.go",
"ui_webview_windows.go",
"updater.go",
"resource_windows_amd64.syso",
"@llvm_mingw//:all_files",
"@go_default_sdk//:files",
],
outs = ["Eagle0.exe"],
cmd = """
set -e
# Find llvm-mingw root
LLVM_MINGW=""
for f in $(locations @llvm_mingw//:all_files); do
if [[ "$$f" == *"/bin/x86_64-w64-mingw32-clang" ]]; then
LLVM_MINGW=$$(cd $$(dirname $$(dirname $$f)) && pwd)
break
fi
done
if [[ -z "$$LLVM_MINGW" ]]; then
echo "ERROR: Could not find llvm-mingw toolchain"
exit 1
fi
echo "Using llvm-mingw at: $$LLVM_MINGW"
# Find Go SDK from Bazel dependency - use absolute paths
GO=""
GO_SDK=""
SANDBOX_ROOT="$$PWD"
for f in $(locations @go_default_sdk//:files); do
if [[ "$$f" == */bin/go && ! "$$f" == *.* ]]; then
GO="$$SANDBOX_ROOT/$$f"
GO_SDK=$$(cd $$(dirname $$(dirname "$$SANDBOX_ROOT/$$f")) && pwd)
break
fi
done
if [[ -z "$$GO" ]]; then
echo "ERROR: Could not find Go binary in @go_default_sdk//:files"
echo "Available files:"
for f in $(locations @go_default_sdk//:files); do
echo " $$f"
done | head -20
exit 1
fi
export GOROOT="$$GO_SDK"
echo "Using Go SDK at: $$GO_SDK"
$$GO version
# Set up cross-compilation environment
export CGO_ENABLED=1
export GOOS=windows
export GOARCH=amd64
export CC="$$LLVM_MINGW/bin/x86_64-w64-mingw32-clang"
export CXX="$$LLVM_MINGW/bin/x86_64-w64-mingw32-clang++"
export AR="$$LLVM_MINGW/bin/x86_64-w64-mingw32-ar"
# Verify cross-compiler works
echo "Testing cross-compiler..."
$$CC --version | head -1
# Create temp build directory
BUILD_DIR=$$(mktemp -d)
trap "chmod -R u+w $$BUILD_DIR 2>/dev/null; rm -rf $$BUILD_DIR" EXIT
# Set up Go environment variables for sandbox
export HOME=$$BUILD_DIR
export GOPATH=$$BUILD_DIR/go
export GOCACHE=$$BUILD_DIR/cache
mkdir -p $$GOPATH $$GOCACHE
# Create Go module structure
mkdir -p $$BUILD_DIR/installer
cd $$BUILD_DIR/installer
# Initialize go module
cat > go.mod << 'GOMOD'
module github.com/nolen777/eagle0/src/main/go/net/eagle0/clients/win/installer
go 1.23
require github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
GOMOD
cat > go.sum << 'GOSUM'
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
GOSUM
# Copy source files from Bazel sandbox (exclude Go SDK and llvm files)
echo "Copying source files from $$SANDBOX_ROOT..."
for src in $(SRCS); do
# Skip Go SDK and toolchain files
if [[ "$$src" == *"go_default_sdk"* || "$$src" == *"llvm_mingw"* || "$$src" == *"rules_go"* ]]; then
continue
fi
case "$$src" in
*.go)
echo " Copying: $$src"
cp "$$SANDBOX_ROOT/$$src" . || { echo "Failed to copy $$src"; exit 1; }
;;
*.syso)
# Copy the 64-bit resource file (contains icon and version info)
echo " Copying resource: $$src"
cp "$$SANDBOX_ROOT/$$src" ./resource_windows.syso || { echo "Failed to copy $$src"; exit 1; }
;;
esac
done
echo "Files copied:"
ls -la *.go 2>/dev/null || echo "No files copied!"
# Download webview dependency
$$GO mod download
# Build with GUI subsystem flag
# MANIFEST_PUBLIC_KEY will be empty if not set via --action_env, which is fine
# (installer handles empty public key gracefully by skipping signature verification)
echo "Building Windows installer with WebView..."
OUTPUT_EXE="$$SANDBOX_ROOT/$@"
$$GO build -v \\
-ldflags="-H windowsgui -X 'main.manifestPublicKey=$$MANIFEST_PUBLIC_KEY'" \\
-o "$$OUTPUT_EXE" \\
.
echo "Build complete!"
ls -la "$$OUTPUT_EXE"
""",
stamp = 1,
# Exclude from wildcard patterns - this target requires --action_env=MANIFEST_PUBLIC_KEY
tags = ["manual"],
visibility = ["//visibility:public"],
)
# Native binary for testing on current platform (console only)
go_binary(
name = "eagle_installer",
embed = [":installer_lib"],
pure = "on",
visibility = ["//visibility:public"],
x_defs = {
"main.manifestPublicKey": "{MANIFEST_PUBLIC_KEY}",
"main.manifestPublicKey": "{STABLE_MANIFEST_PUBLIC_KEY}",
},
)
@@ -11,10 +11,12 @@ import (
)
var (
fileLocation string
exeLocation string
invitationFile string
ui *UI
fileLocation string
exeLocation string
invitationFile string
ui UIInterface
invitationCode string
replaceInstaller string
)
func init() {
@@ -32,7 +34,7 @@ func main() {
helpFlag := flag.Bool("help", false, "Show help message")
hFlag := flag.Bool("h", false, "Show help message")
codeFlag := flag.String("code", "", "Invitation code for new account registration")
replaceInstaller := flag.String("replace-installer", "", "Path to old installer to replace (internal use)")
replaceFlag := flag.String("replace-installer", "", "Path to old installer to replace (internal use)")
flag.Parse()
if *helpFlag || *hFlag {
@@ -40,21 +42,41 @@ func main() {
return
}
// Initialize UI
ui = NewUI()
// Store flags for use in runInstallerWithUI
invitationCode = *codeFlag
replaceInstaller = *replaceFlag
// Initialize UI - try WebView first, fall back to console
if webviewUI := NewWebViewUI(); webviewUI != nil {
ui = webviewUI
// Run installer logic in a goroutine since WebView needs the main thread
go runInstallerWithUI()
// WebView event loop (blocks until window closes)
ui.Run()
return
}
// Fall back to console UI
ui = NewConsoleUI()
runInstallerWithUI()
}
// runInstallerWithUI runs the installer logic with the initialized UI
func runInstallerWithUI() {
defer ui.Close()
// Clean up temp installer files from previous updates
cleanupTempInstallerFiles()
// Handle installer replacement if launched with --replace-installer flag
if *replaceInstaller != "" {
handleInstallerReplacement(*replaceInstaller)
if replaceInstaller != "" {
handleInstallerReplacement(replaceInstaller)
return
}
// Run the main installer logic
ui.ShowBanner()
if err := runInstallerLogic(*codeFlag); err != nil {
if err := runInstallerLogic(invitationCode); err != nil {
ui.ShowError(fmt.Sprintf("%v", err))
ui.WaitForKeypress("Press Enter to exit...")
os.Exit(1)
@@ -65,7 +87,7 @@ func showHelp() {
fmt.Println("Eagle0 Installer")
fmt.Println()
fmt.Println("USAGE:")
fmt.Println(" EagleInstaller.exe [OPTIONS]")
fmt.Println(" Eagle0.exe [OPTIONS]")
fmt.Println()
fmt.Println("OPTIONS:")
fmt.Println(" --help, -h Show this help message")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 124 KiB

@@ -6,21 +6,25 @@ import (
"time"
)
// UI provides a simple console-based progress display
type UI struct {
// ConsoleUI provides a simple console-based progress display.
// It implements UIInterface and serves as the fallback UI when WebView is not available.
type ConsoleUI struct {
lastStatus string
lastProgress int
startTime time.Time
}
func NewUI() *UI {
return &UI{
// Ensure ConsoleUI implements UIInterface
var _ UIInterface = (*ConsoleUI)(nil)
func NewConsoleUI() *ConsoleUI {
return &ConsoleUI{
startTime: time.Now(),
}
}
// ShowBanner displays the application header
func (ui *UI) ShowBanner() {
func (ui *ConsoleUI) ShowBanner() {
fmt.Println()
fmt.Println("╔══════════════════════════════════════════════════════════╗")
fmt.Println("║ Eagle0 Installer ║")
@@ -29,7 +33,7 @@ func (ui *UI) ShowBanner() {
}
// UpdateStatus shows the current status message
func (ui *UI) UpdateStatus(status string) {
func (ui *ConsoleUI) UpdateStatus(status string) {
if status != ui.lastStatus {
ui.lastStatus = status
fmt.Printf("► %s\n", status)
@@ -37,7 +41,7 @@ func (ui *UI) UpdateStatus(status string) {
}
// UpdateProgress shows a progress bar (0-100)
func (ui *UI) UpdateProgress(percent int, description string) {
func (ui *ConsoleUI) UpdateProgress(percent int, description string) {
if percent < 0 {
percent = 0
}
@@ -64,33 +68,43 @@ func (ui *UI) UpdateProgress(percent int, description string) {
}
// ShowSuccess displays a success message
func (ui *UI) ShowSuccess(message string) {
func (ui *ConsoleUI) ShowSuccess(message string) {
fmt.Printf("\n✓ %s\n", message)
}
// ShowError displays an error message
func (ui *UI) ShowError(message string) {
func (ui *ConsoleUI) ShowError(message string) {
fmt.Printf("\n✗ ERROR: %s\n", message)
}
// ShowWarning displays a warning message
func (ui *UI) ShowWarning(message string) {
func (ui *ConsoleUI) ShowWarning(message string) {
fmt.Printf("\n⚠ WARNING: %s\n", message)
}
// ShowInfo displays an info message
func (ui *UI) ShowInfo(message string) {
func (ui *ConsoleUI) ShowInfo(message string) {
fmt.Printf(" %s\n", message)
}
// WaitForKeypress waits for user input before continuing
func (ui *UI) WaitForKeypress(prompt string) {
func (ui *ConsoleUI) WaitForKeypress(prompt string) {
fmt.Printf("\n%s", prompt)
fmt.Scanln()
}
// ShowElapsedTime shows how long the operation took
func (ui *UI) ShowElapsedTime() {
func (ui *ConsoleUI) ShowElapsedTime() {
elapsed := time.Since(ui.startTime)
fmt.Printf("\nCompleted in %.1f seconds\n", elapsed.Seconds())
}
// Run is a no-op for console UI (no event loop needed)
func (ui *ConsoleUI) Run() {
// Console UI doesn't need an event loop
}
// Close is a no-op for console UI (no cleanup needed)
func (ui *ConsoleUI) Close() {
// Console UI doesn't need cleanup
}
@@ -0,0 +1,38 @@
package main
// UIInterface defines the interface for installer UI implementations.
// This allows switching between console UI (fallback) and WebView UI (Windows with WebView2).
type UIInterface interface {
// ShowBanner displays the application header/title
ShowBanner()
// UpdateStatus shows the current status message
UpdateStatus(status string)
// UpdateProgress shows a progress bar (0-100) with description
UpdateProgress(percent int, description string)
// ShowSuccess displays a success message
ShowSuccess(message string)
// ShowError displays an error message
ShowError(message string)
// ShowWarning displays a warning message
ShowWarning(message string)
// ShowInfo displays an info message
ShowInfo(message string)
// WaitForKeypress waits for user input before continuing
WaitForKeypress(prompt string)
// ShowElapsedTime shows how long the operation took
ShowElapsedTime()
// Run starts the UI event loop (blocking for WebView, no-op for console)
Run()
// Close cleans up UI resources
Close()
}
@@ -0,0 +1,31 @@
//go:build !cgo || !windows
package main
// WebViewUI stub for pure Go builds (no CGO).
// This stub allows the code to compile without CGO/WebView dependencies.
// NewWebViewUI returns nil, causing main.go to fall back to ConsoleUI.
type WebViewUI struct{}
// Ensure WebViewUI implements UIInterface (for compile-time checking)
var _ UIInterface = (*WebViewUI)(nil)
// NewWebViewUI returns nil in pure Go builds (no WebView available).
func NewWebViewUI() *WebViewUI {
return nil
}
// The following methods implement UIInterface but will never be called
// since NewWebViewUI returns nil and main.go checks for nil.
func (ui *WebViewUI) ShowBanner() {}
func (ui *WebViewUI) UpdateStatus(status string) {}
func (ui *WebViewUI) UpdateProgress(percent int, desc string) {}
func (ui *WebViewUI) ShowSuccess(message string) {}
func (ui *WebViewUI) ShowError(message string) {}
func (ui *WebViewUI) ShowWarning(message string) {}
func (ui *WebViewUI) ShowInfo(message string) {}
func (ui *WebViewUI) WaitForKeypress(prompt string) {}
func (ui *WebViewUI) ShowElapsedTime() {}
func (ui *WebViewUI) Run() {}
func (ui *WebViewUI) Close() {}
@@ -0,0 +1,268 @@
//go:build cgo && windows
package main
import (
"fmt"
"sync"
"time"
"github.com/webview/webview_go"
)
// WebViewUI provides a WebView2-based GUI for the installer on Windows.
// It implements UIInterface and provides a modern HTML/CSS/JS interface.
type WebViewUI struct {
w webview.WebView
startTime time.Time
ready chan struct{}
mu sync.Mutex
lastStatus string
}
// Ensure WebViewUI implements UIInterface
var _ UIInterface = (*WebViewUI)(nil)
const htmlTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #e8e8e8;
height: 100vh;
padding: 40px;
display: flex;
flex-direction: column;
}
.logo {
font-size: 42px;
font-weight: 700;
letter-spacing: 4px;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 30px;
}
.status {
font-size: 16px;
margin: 20px 0;
min-height: 24px;
color: #b8b8b8;
}
.progress-container {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
height: 28px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
}
.progress-bar {
background: linear-gradient(90deg, #00d4ff 0%, #7b2cbf 100%);
height: 100%;
width: 0%;
transition: width 0.3s ease-out;
border-radius: 12px;
}
.percentage {
text-align: center;
margin-top: 12px;
font-size: 18px;
font-weight: 600;
color: #00d4ff;
}
.message {
margin-top: 20px;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.message.success {
display: block;
background: rgba(0, 255, 136, 0.15);
border-left: 4px solid #00ff88;
color: #00ff88;
}
.message.error {
display: block;
background: rgba(255, 71, 87, 0.15);
border-left: 4px solid #ff4757;
color: #ff4757;
}
.message.warning {
display: block;
background: rgba(255, 193, 7, 0.15);
border-left: 4px solid #ffc107;
color: #ffc107;
}
.message.info {
display: block;
background: rgba(0, 212, 255, 0.1);
border-left: 4px solid #00d4ff;
color: #a0d4e8;
}
.elapsed {
margin-top: auto;
font-size: 13px;
color: #666;
}
</style>
</head>
<body>
<div class="logo">EAGLE0</div>
<div class="status" id="status">Initializing...</div>
<div class="progress-container">
<div class="progress-bar" id="progress"></div>
</div>
<div class="percentage" id="percentage">0%</div>
<div class="message" id="message"></div>
<div class="elapsed" id="elapsed"></div>
<script>
function updateProgress(percent, description) {
document.getElementById('progress').style.width = percent + '%';
document.getElementById('percentage').textContent = percent + '%';
if (description) {
document.getElementById('status').textContent = description;
}
}
function updateStatus(status) {
document.getElementById('status').textContent = status;
}
function showMessage(type, text) {
const msg = document.getElementById('message');
msg.className = 'message ' + type;
msg.textContent = text;
}
function showElapsed(seconds) {
document.getElementById('elapsed').textContent = 'Completed in ' + seconds.toFixed(1) + ' seconds';
}
function clearMessage() {
document.getElementById('message').className = 'message';
}
</script>
</body>
</html>`
// NewWebViewUI creates a new WebView-based UI.
// Returns nil if WebView2 is not available.
func NewWebViewUI() *WebViewUI {
w := webview.New(false)
if w == nil {
return nil
}
w.SetTitle("Eagle0 Installer")
w.SetSize(520, 340, webview.HintFixed)
w.SetHtml(htmlTemplate)
ui := &WebViewUI{
w: w,
startTime: time.Now(),
ready: make(chan struct{}),
}
return ui
}
// ShowBanner is a no-op for WebView (logo is in HTML)
func (ui *WebViewUI) ShowBanner() {
// Logo is already in the HTML template
}
// UpdateStatus shows the current status message
func (ui *WebViewUI) UpdateStatus(status string) {
ui.mu.Lock()
defer ui.mu.Unlock()
if status == ui.lastStatus {
return
}
ui.lastStatus = status
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("updateStatus(%q)", escapeJS(status)))
})
}
// UpdateProgress shows a progress bar (0-100)
func (ui *WebViewUI) UpdateProgress(percent int, description string) {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("updateProgress(%d, %q)", percent, escapeJS(description)))
})
}
// ShowSuccess displays a success message
func (ui *WebViewUI) ShowSuccess(message string) {
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("showMessage('success', %q)", escapeJS(message)))
})
}
// ShowError displays an error message
func (ui *WebViewUI) ShowError(message string) {
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("showMessage('error', %q)", escapeJS(message)))
})
}
// ShowWarning displays a warning message
func (ui *WebViewUI) ShowWarning(message string) {
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("showMessage('warning', %q)", escapeJS(message)))
})
}
// ShowInfo displays an info message
func (ui *WebViewUI) ShowInfo(message string) {
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("showMessage('info', %q)", escapeJS(message)))
})
}
// WaitForKeypress waits for user acknowledgment
// For WebView, we just pause briefly to let the user see the message
func (ui *WebViewUI) WaitForKeypress(prompt string) {
ui.UpdateStatus(prompt)
time.Sleep(3 * time.Second)
}
// ShowElapsedTime shows how long the operation took
func (ui *WebViewUI) ShowElapsedTime() {
elapsed := time.Since(ui.startTime).Seconds()
ui.w.Dispatch(func() {
ui.w.Eval(fmt.Sprintf("showElapsed(%f)", elapsed))
})
}
// Run starts the WebView event loop (blocking)
func (ui *WebViewUI) Run() {
ui.w.Run()
}
// Close cleans up WebView resources
func (ui *WebViewUI) Close() {
ui.w.Destroy()
}
// escapeJS escapes a string for safe use in JavaScript
func escapeJS(s string) string {
// The %q format in Sprintf handles most escaping, but we use it for the whole string
// So we just return the string as-is since Sprintf("%q", ...) will handle it
return s
}
@@ -32,10 +32,10 @@ type InstallerUpdateInfo struct {
type Updater struct {
serverURL string
httpClient *http.Client
ui *UI
ui UIInterface
}
func NewUpdater(serverURL string, ui *UI) *Updater {
func NewUpdater(serverURL string, ui UIInterface) *Updater {
return &Updater{
serverURL: serverURL,
httpClient: &http.Client{
@@ -23,10 +23,10 @@
"CompanyName": "Eagle0",
"FileDescription": "Eagle0 Installer",
"FileVersion": "1.0.0.0",
"InternalName": "EagleInstaller",
"InternalName": "Eagle0",
"LegalCopyright": "Copyright (c) 2024 Eagle0",
"LegalTrademarks": "",
"OriginalFilename": "EagleInstaller.exe",
"OriginalFilename": "Eagle0.exe",
"PrivateBuild": "",
"ProductName": "Eagle0",
"ProductVersion": "1.0.0.0",
@@ -157,7 +157,10 @@ message AddSettingsRequest {
repeated AddSettingsKeyValue settings = 1;
}
message AddSettingsResponse {}
message AddSettingsResponse {
bool success = 1;
string error_message = 2; // Only set if success is false
}
message AddSettingsKeyValue {
string key = 1;
@@ -2558,6 +2558,7 @@ ranger/male/000007.996945201.png
### fixed
fixed/aethelred_the_unready.png
fixed/alakanda.png
fixed/aldric_the_overlooked.png
fixed/bergil.png
fixed/bob_brokenbow.png
fixed/bregos_fyar.png
@@ -2565,39 +2566,38 @@ fixed/brewster.png
fixed/bridget.png
fixed/bromus.png
fixed/cardinal_richeliu.png
fixed/darth_plagueis.png
fixed/david_duckplucker.png
fixed/deckard_cain.png
fixed/eagle.png
fixed/eagle_with_hat.png
fixed/elena_fyar.png
fixed/elzix.png
fixed/flea.png
fixed/generic.png
fixed/geoffrey_the_bozo.png
fixed/hedrick_the_hedge_merchant.png
fixed/ikhaan_tarn.png
fixed/ironmaw.png
fixed/jake.png
fixed/jeb.png
fixed/jesse_the_body_ventura.png
fixed/john_ranil.png
fixed/kriss_kross.png
fixed/mack_daddy.png
fixed/karvax_the_fleshless.png
fixed/mad_mal.png
fixed/magnus_the_boulder_varnok.png
fixed/marty_mcpolitics.png
fixed/mei_shen.png
fixed/mesrop_mashtots.png
fixed/norfolk.png
fixed/old_marek_the_learned.png
fixed/rabbit_spearer.png
fixed/randall_galor.png
fixed/ringo.png
fixed/rod_rustyhelm.png
fixed/roger.png
fixed/sadar_rakon.png
fixed/sam_swordthatsbent.png
fixed/shu_lien.png
fixed/skeletor.png
fixed/trap_jaw.png
fixed/silvio_the_smooth.png
fixed/the_tumbler.png
fixed/tomlin_the_tuneful.png
fixed/turnil.png
fixed/ul.png
fixed/voros_the_undying.png
fixed/zander_the_restless.png
### mage
mage/female/000002.3495282493.png
mage/female/000003.1000321804.png
1 ### paladin
2558 ### fixed
2559 fixed/aethelred_the_unready.png
2560 fixed/alakanda.png
2561 fixed/aldric_the_overlooked.png
2562 fixed/bergil.png
2563 fixed/bob_brokenbow.png
2564 fixed/bregos_fyar.png
2566 fixed/bridget.png
2567 fixed/bromus.png
2568 fixed/cardinal_richeliu.png
fixed/darth_plagueis.png
2569 fixed/david_duckplucker.png
fixed/deckard_cain.png
2570 fixed/eagle.png
2571 fixed/eagle_with_hat.png
2572 fixed/elena_fyar.png
2573 fixed/elzix.png
fixed/flea.png
2574 fixed/generic.png
2575 fixed/geoffrey_the_bozo.png
2576 fixed/hedrick_the_hedge_merchant.png
2577 fixed/ikhaan_tarn.png
2578 fixed/ironmaw.png
2579 fixed/jake.png
fixed/jeb.png
fixed/jesse_the_body_ventura.png
2580 fixed/john_ranil.png
2581 fixed/kriss_kross.png fixed/karvax_the_fleshless.png
fixed/mack_daddy.png
2582 fixed/mad_mal.png
2583 fixed/magnus_the_boulder_varnok.png
2584 fixed/marty_mcpolitics.png
2585 fixed/mei_shen.png
2586 fixed/mesrop_mashtots.png
2587 fixed/norfolk.png
2588 fixed/old_marek_the_learned.png
2589 fixed/rabbit_spearer.png
2590 fixed/randall_galor.png
fixed/ringo.png
2591 fixed/rod_rustyhelm.png
fixed/roger.png
2592 fixed/sadar_rakon.png
2593 fixed/sam_swordthatsbent.png
2594 fixed/shu_lien.png fixed/silvio_the_smooth.png
2595 fixed/skeletor.png fixed/the_tumbler.png
2596 fixed/trap_jaw.png fixed/tomlin_the_tuneful.png
2597 fixed/turnil.png
2598 fixed/ul.png
2599 fixed/voros_the_undying.png
2600 fixed/zander_the_restless.png
2601 ### mage
2602 mage/female/000002.3495282493.png
2603 mage/female/000003.1000321804.png
+13 -13
View File
@@ -13,29 +13,29 @@ Bromus 25 65 60 45 97 292 39 98 81 66 60 mage FALSE fixed/bromus.png male Bromu
Turnil 75 74 80 80 90 399 95 70 72 92 80 ranger FALSE fixed/turnil.png male Turnil was once a cleric, exploring the lands to the east, healing the sick and fighting evil. Eventually he became convinced that he could do more good fighting evil indirectly, becoming a ranger and using his stealth abilities to get others to avoid fighting. Now he serves wherever he thinks he can do the most good for the people. stern, battle-hardened, a survivor
Bob Brokenbow 38 79 77 23 28 245 55 20 38 71 77 ranger FALSE fixed/bob_brokenbow.png male Bob Brokenbow is a competent scout, uninspiring but loyal. He got his nickname from his habit of somehow breaking all his bows, no matter how strong the wood. This is especially annoying to his allies, since the bow is the only weapon he's any good at using. reliable, uninspiring, loyal
Rod Rustyhelm 40 80 75 25 5 225 80 75 88 91 75 engineer FALSE fixed/rod_rustyhelm.png male Rod Rustyhelm is one of the most dim-witted heroes in all the land. He does have occasional moments of "inspiration," where he comes up with some ridiculously hare-brained idea and decides it's genius, and won't shut up about it. Still, he does have some skill with weapons and can be trusted to repair a bridge or even build a ballista, when needed. an idiot, eager, mostly harmless
Kriss Kross 95 75 95 80 60 405 91 98 85 63 95 noProfession FALSE fixed/kriss_kross.png other Kriss Kross is a relic from another age; most people have either forgotten about them, or wish they had. Don't try to compare them to another bad little fad. They'll give you something that you never had. Kriss Kross'll make you Jump Jump. likely to make you jump jump, musical, dated
The Tumbler 95 75 95 80 60 405 91 98 85 63 95 noProfession FALSE fixed/the_tumbler.png other The Tumbler is an acrobat from a bygone era of entertainment. Most people have either forgotten about them or wish they had. They insist their act is timeless, though audiences tend to disagree. Still, their agility is undeniable, and they can be surprisingly useful in a fight. acrobatic, outdated, persistent
Elena Fyar 75 90 85 85 90 425 82 86 71 91 85 paladin TRUE fixed/elena_fyar.png female Champions of Justice Elena Fyar is the daughter of the King and was once betrothed to his right-hand man, Shardok Ikhaan Tarn, but eventually refused him. Many at court blamed her for Tarn's betrayal, and she was forced to leave the court. She now commands her own faction. In truth, she was merely the first to see Tarn's unbridled ego and ambition, and none heeded her warnings. She is proud, but also hopes for eventual reconciliation with her father. proud, noble, formerly betrothed to Ikhaan Tarn, daughter of King Bregos Fyar
Geoffrey the Bozo 95 80 75 85 27 362 2 3 49 1 75 noProfession FALSE fixed/geoffrey_the_bozo.png male Geoffrey the Bozo is a mysterious stranger. No one takes him seriously, since both his actions and his manner of speech are ridiculous. He's untrustworthy, unambitious, and a coward. He's therefore always underestimated. But don't be fooled; he's surprisingly strong, pretty good in a fight, and overall a very useful companion to have around. He's a nightmare in your dreams! surprisingly strong, useful, mysterious
Æthelred the Unready 65 40 85 25 30 245 78 61 27 15 85 engineer FALSE fixed/aethelred_the_unready.png male Æthelred the Unready was once left in charge of a province, a charge for which he was utterly unsuited, and he did a terrible job. That earned him his nickname. Still, he's proven himself capable in the war, helping to repair battle damage in impoverished towns and even helping out in a battle now and then. ever-present, useful in a pinch, unwanted
Randall Galor 96 85 100 71 63 415 98 90 47 95 100 engineer TRUE fixed/randall_galor.png male The Bulwark Brotherhood Randall Galor is a remarkable man. He's both a warrior skilled in personal combat, and a capable engineer. He served as an engineer in the King's army for several years, but went rogue when the civil war broke out. He believes that by fortifying his territory and by building mighty catapults and trebuchets to batter down the defenses of others, he's sure to emerge victorious. a warrior, a mechanic, remarkable
Jesse "The Body" Ventura 100 85 98 91 72 446 23 97 86 91 98 champion TRUE fixed/jesse_the_body_ventura.png male We Ain't Got Time to Bleed Jesse "The Body" Ventura has tried a few routes to power. His comical, yet effective, wrestling techniques make him nearly unstoppable in single combat, but because he could never defer to another's authority, he never served either the King nor the Eagle. Now he's seeking a political path to power, but he's still not afraid to rumble when needed. In addition to his brute strength, his bald head and ridiculous mustache make him greatly feared. a wrestler, a politician, mustachioed
Ringo 37 71 60 71 80 319 91 45 79 71 60 noProfession FALSE fixed/ringo.png male Ringo gave up a promising career as a beautician to become a drummer. He travels the land, telling jokes and playing the drums for the entertainment of children everywhere. He's not sure how he ended up involved in this war. a drummer, a beautician, affable
Darth Plagueis 48 72 94 79 97 390 17 99 56 91 90 necromancer TRUE fixed/darth_plagueis.png male The Sith Lords Darth Plagueis tried to become so powerful that he could prevent the people he cared about from dying. That didn't work, so instead he became a necromancer. He is an equal opportunity practitioner of the dark arts. He'll reanimate his brother, his enemy, or the ex-girlfriends of his followers with equal passion. evil, power-hungry, terrifying
Deckard Cain 17 22 60 60 93 252 95 12 65 95 60 noProfession FALSE fixed/deckard_cain.png male Deckard Cain is highly learned, and won't hesitate to let you know. Try as you might to get away from his constant blabbering, he'll keep insisting that you stay a while and listen. In fact, he says that constantly: "Stay a while, and listen." likely to ask you to stay a while and listen, learned, talkative
Trap Jaw 99 91 98 33 37 358 17 87 41 63 98 engineer FALSE fixed/trap_jaw.png male Trap Jaw was an engineer in Skeletor's employ, before Skeletor got angry and destroyed his jaw and one of his arms. Trap Jaw was able to replace the missing parts with machinery. He doesn't seem at all intelligent, yet he does come up with clever contraptions for war. No one knows why he is blue. foolish, disloyal, metal-eating
Jeb! 56 43 61 8 81 249 62 89 12 14 61 noProfession FALSE fixed/jeb.png male Jeb is a low-energy politician, and basically a loser. He had some surprising successes earlier in his career, and some thought he was destined for great power, like his father and brother before him. When he made a move for power, though, it turned out that no one wanted him, and it turns out his father and brother were not that great, either. Now he is adrift, willing to serve the right leader but noticed by almost no one. unwanted, low-energy, a loser
Roger the Shrubber 51 95 80 85 91 402 92 15 73 71 80 ranger TRUE fixed/roger.png male Traveling Shrubbers Anonymous Roger the Shrubber is a traveling salesman, who carries shrubberies and the tools for planting and maintaining shrubberies in this cart. He is under considerable economic stress at this period in history. He disapproves of saying "ni" to old ladies. cart-riding, a traveling salesmen, under considerable economic stress at this period in history
Magnus "The Boulder" Varnok 100 85 98 91 72 446 23 97 86 91 98 champion TRUE fixed/magnus_the_boulder_varnok.png male The Iron Fist Confederacy Magnus "The Boulder" Varnok rose to fame in the fighting pits, where his theatrical style and booming voice made him a crowd favorite. Unsatisfied with mere athletic glory, he turned to politics, believing that if he could command an arena, he could command a province. He defers to no one, and his bald head and magnificent mustache are known throughout the land. a pit fighter, a politician, mustachioed
Tomlin the Tuneful 37 71 60 71 80 319 91 45 79 71 60 noProfession FALSE fixed/tomlin_the_tuneful.png male Tomlin abandoned a promising apprenticeship as a cobbler to pursue his true passion: percussion. He wanders the land with his drums, entertaining children and lifting spirits wherever he goes. He's not sure how he got caught up in this war, but he's determined to make the best of it. a drummer, formerly a cobbler, good-natured
Voros the Undying 48 72 94 79 97 390 17 99 56 91 90 necromancer TRUE fixed/voros_the_undying.png male The Ashen Circle Voros was once a healer who became obsessed with conquering death itself. When his beloved died despite his efforts, he turned to forbidden arts, reasoning that if he could not prevent death, he would simply reverse it. Now he leads a cult of necromancers who believe that the boundary between life and death is merely an inconvenience. obsessive, grief-stricken, terrifying
Old Marek the Learned 17 22 60 60 93 252 95 12 65 95 60 noProfession FALSE fixed/old_marek_the_learned.png male Old Marek has forgotten more than most scholars will ever know, and he's eager to share what he remembers with anyone who will listen. Unfortunately, he has difficulty telling when his audience has lost interest, and his lectures can stretch on for hours. He has a habit of saying "Now hold on, you'll want to hear this" just as you're trying to escape. Still, his knowledge has proven invaluable to those patient enough to hear him out. long-winded, knowledgeable, oblivious
Ironmaw 99 91 98 33 37 358 17 87 41 63 98 engineer FALSE fixed/ironmaw.png male Ironmaw was an engineer who lost his jaw and arm in a siege gone wrong. Rather than accept his fate, he built mechanical replacements from scavenged metal. He's not particularly bright, but he has an intuitive genius for war machines. No one knows why his skin has a grayish-blue pallor, and he refuses to discuss it. resourceful, dim-witted, mechanically gifted
Aldric the Overlooked 56 43 61 8 81 249 62 89 12 14 61 noProfession FALSE fixed/aldric_the_overlooked.png male Aldric comes from a prestigious family of rulers, and everyone assumed he would follow in their footsteps. When he finally made his bid for power, however, no one rallied to his cause. It turned out that his family's reputation had soured, and Aldric himself inspired no one. Now he drifts from faction to faction, hoping someone will give him a chance, but he is noticed by almost no one. uninspiring, from a famous family, a disappointment
Hedrick the Hedge-Merchant 51 95 80 85 91 402 92 15 73 71 80 ranger TRUE fixed/hedrick_the_hedge_merchant.png male The Verdant Fellowship Hedrick travels the land selling decorative shrubberies from his cart, along with the tools needed to plant and maintain them. Business has been difficult during the war, and he's grown increasingly desperate. He has strong opinions about proper manners and detests rudeness toward the elderly. a traveling salesman, polite, economically stressed
Rabbit Spearer 18 23 60 3 4 108 73 6 27 81 60 noProfession FALSE fixed/rabbit_spearer.png male Rabbit Spearer is the absolute worst. Everyone hates him, but sometimes he can talk his way into someone's retinue just as a warm body. He's quite stupid, and his strength and agility are not much better. the worst, hated, an idiot
Norfolk 81 71 87 93 73 405 30 98 92 70 87 paladin TRUE fixed/norfolk.png male Duchy of Norfolk The Duke of Norfolk is a puzzling presence in this environment, since there is no place called Norfolk here. Nonetheless, he managed to rise to power, leading a faction of discontents. a duke, unexplained, unremarkable
Elzix 73 85 91 86 68 403 41 58 99 96 91 engineer FALSE fixed/elzix.png male Elzix has been overlooked his whole life, but he's okay with that. He tends to settle down in one place and just quietly do the work, often making outrageous and amazing artifacts that he'll let adventurers gamble for. He's a lot of fun at parties. affable, honest, clever
Ul 71 52 92 81 95 391 87 70 65 88 92 paladin FALSE fixed/ul.png male Ul is an affable shaman who one day dreams of becoming a Far Seer. No one knows where he came from, or how an orc like him got to the Kingdom, or what an orc is, anyway, but he doesn't let that bother him. He's just here to kick butt and heal wounds. affable, a shaman, an orc
Ul 71 52 92 81 95 391 87 70 65 88 92 paladin FALSE fixed/ul.png male Ul is an affable shaman who dreams of one day mastering the deeper mysteries of his craft. No one knows where he came from, or how a green-skinned tusked warrior like him arrived in the Kingdom, but he doesn't let that bother him. He's just here to fight evil and heal the wounded. affable, a shaman, an outsider
Brewster 82 32 87 93 56 350 79 12 88 71 87 noProfession FALSE fixed/brewster.png male Brewster is a man of no special talents, but generally a decent guy to have around. He travels the land, but nobody knows why. Nobody had even heard of him before the war began. mysterious, friendly, talkative
Mesrop Mashtots 36 90 60 75 98 359 95 81 50 81 60 ranger FALSE fixed/mesrop_mashtots.png male Mesrop Mashtots is a great scholar, who has invented many alphabets, because why stop with just one? He's traveled to far-away lands and lived to tell the tale. He came back to find the Kingdom in civil war, and has not yet chosen his side. scholarly, an inventor of alphabets, well-traveled
David Duckplucker 21 20 60 14 7 122 80 12 60 81 60 ranger FALSE fixed/david_duckplucker.png male David Duckplucker is an idiot, but he throws himself into every project with great gusto. His friends started calling him Duckplucker as a challenge, but try as he might, he has never successfully plucked a duck. You could do worse than to hire David Duckplucker, but you couldn't do much worse. an idiot, eager, incapable of plucking a duck
Pea-Brained Jake-o the Peacock Floofio Majestico 91 28 78 100 23 320 32 51 100 14 78 ranger FALSE fixed/jake.png male Pea-Brained Jake-o the Peacock Floofio Majestico is one of the most beloved heroes in all the land. He would never hurt anyone on purpose, and is quite clumsy, yet somehow a skilled hunter. Most of all, he's extraordinarily handsome, with a lush red mane that is the envy of all the world. handsome, lovable, a doofus
Flea 85 95 90 96 82 448 45 91 93 74 90 champion TRUE fixed/flea.png male The Syncopated Sanctum Flea is a crazy bassist. Soft spoken with a broken jaw, step outside but not to brawl. Fully loaded we got snacks and supplies. I don't ever wanna feel like I did that day, take me to the place I love, take me all the way. a bassist, musical, crazy
Daddy Mac 92 80 90 83 71 416 61 43 81 95 90 noProfession FALSE fixed/mack_daddy.png male Daddy Mac is a traveling musician. He's well liked everywhere he goes, especially by the ladies. No one knows exactly what his motivations are, but one thing's for sure: The Daddy Mac'll make you Jump Jump. He'll make you bump bump, wiggle and shake your rump. useful, a ladies' man, likely to make you jump jump
Zander the Restless 85 95 90 96 82 448 45 91 93 74 90 champion TRUE fixed/zander_the_restless.png male The Syncopated Sanctum Zander is a wild-eyed musician who plays the lute with a ferocity no one has seen before. He's soft-spoken until he performs, at which point he becomes a whirlwind of energy. He founded a commune of like-minded artists who believe that music can change the world, or at least make it more bearable. a musician, intense, unpredictable
Silvio the Smooth 92 80 90 83 71 416 61 43 81 95 90 noProfession FALSE fixed/silvio_the_smooth.png male Silvio is a traveling entertainer known for his charm and his way with words. He's popular wherever he goes, especially among the ladies. No one is quite sure what his goals are, but he's pleasant company and surprisingly handy in a scrap. charming, a ladies' man, easygoing
Bridget 83 91 92 98 100 464 62 97 92 100 92 mage TRUE fixed/bridget.png female Vengeance Bridget's teachers and fellow pupils at the Academy constantly underestimated her, and she grew disillusioned. When the Eagle arrived, she jumped at the chance to join his rebellion, and she learned much from him, becoming one of the most powerful wizards in the land. She earned great renown when, in battle against a superior force from the King's army, she initially escaped capture, swam across a river, and single-handedly began raining lightning down on the enemy camp. She was eventually caught, and lost faith when the Eagle failed to rescue or ransom her. After escaping, she formed her own faction, with loyalties to no one. astonishingly powerful, bold, ambitious
Marty McPolitics 24 28 77 12 98 239 3 91 67 8 77 noProfession FALSE fixed/marty_mcpolitics.png male Marty McPolitics is smart, but no one likes him or trusts him, and for good reason. He'd stab a friend in the back for the smallest advantage, and he's no good in a fight. a loser, fickle, ambitious
Skeletor 95 70 95 100 60 420 2 100 40 30 95 necromancer TRUE fixed/skeletor.png male The Evil Warriors Many stories circulate about Skeletor's origins. Is he the uncle of his arch-nemesis, disfigured after a great battle? Is he an invader from another dimension? Why is he blue, and how did he get so muscle-bound when he seems to be lazy and do everything with magic? But many follow him anyway, for he is enormously powerful and lusts for even more. power-hungry, evil, witty
Shu Lien 86 100 92 71 89 438 98 37 45 99 92 champion TRUE fixed/shu_lien.png female Merchant Protection League Shu Lien is a skilled martial artist. She possess now magical or supernatural abilities; her abilities come from her own training and raw physical power. She once fought in the great wars, but now she runs her own trading company, using her talents to protect her caravans. For the right just cause, though, she might be persuaded to rejoin the fight. noble, sad, strict
Karvax the Fleshless 95 70 95 100 60 420 2 100 40 30 95 necromancer TRUE fixed/karvax_the_fleshless.png male The Hollow Throne No one knows what Karvax looked like before the accident that stripped the flesh from his skull, leaving only bone and sinew held together by dark magic. Some say he was a prince; others say he was always a monster. Whatever the truth, he now commands legions of followers drawn to his raw power and his promise that they too can transcend their mortal limitations. power-hungry, mysterious, darkly witty
Mei Shen 86 100 92 71 89 438 98 37 45 99 92 champion TRUE fixed/mei_shen.png female The Jade Caravan Mei Shen is a martial artist of extraordinary skill, trained from childhood in an ancient discipline. She possesses no magical abilities; her prowess comes entirely from decades of rigorous practice. She once served as a bodyguard to nobles, but now runs her own trading company, personally defending her caravans from bandits. For the right cause, she might be persuaded to fight again. disciplined, melancholy, honorable
Can't render this file because it contains an unexpected character in line 5 and column 5.
@@ -10,10 +10,12 @@ object ApiKeys {
// Environment variable names
private val openAIEnvVar = "OPENAI_API_KEY"
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
private val geminiEnvVar = "GEMINI_API_KEY"
// File key names
private val openAIKeyName = "openai_api_key"
private val anthropicKeyName = "anthropic_api_key"
private val geminiKeyName = "gemini_api_key"
private def readDictionary(): Map[String, String] =
Using(scala.io.Source.fromFile(apiKeyFilePath)) { source =>
@@ -54,4 +56,6 @@ object ApiKeys {
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
lazy val gemini: String = getKey(geminiKeyName, geminiEnvVar)
}
@@ -43,6 +43,23 @@ scala_library(
],
)
scala_library(
name = "gemini_service_impl",
srcs = ["GeminiServiceImpl.scala"],
visibility = [
"//visibility:public",
],
deps = [
":api_keys",
":external_text_generation_service_impl",
":rate_limits",
":streaming_text_results",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
],
)
scala_library(
name = "external_text_generation_caller",
srcs = ["ExternalTextGenerationCaller.scala"],
@@ -0,0 +1,179 @@
package net.eagle0.common.llm_integration
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.URI
import java.time.Duration
import java.util.function.Consumer
import org.json4s.{DefaultFormats, JArray, JObject, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.{Json, Serialization}
object GeminiServiceImpl {
val defaultModel: String = "gemini-2.5-flash-lite"
private val apiKey = ApiKeys.gemini
private val baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
private def requestUrl(modelName: String): String =
s"$baseURL/$modelName:streamGenerateContent?alt=sse&key=$apiKey"
private def contentsFor(
inputText: String,
partialCompletion: Option[String]
): Vector[Map[String, Any]] = {
val userMessage = Map(
"role" -> "user",
"parts" -> Vector(Map("text" -> inputText))
)
partialCompletion match {
case Some(partial) =>
Vector(
userMessage,
Map(
"role" -> "model",
"parts" -> Vector(Map("text" -> partial))
)
)
case None => Vector(userMessage)
}
}
private def baseRequest(
modelName: String,
timeoutSeconds: Int
): HttpRequest.Builder =
HttpRequest
.newBuilder()
.uri(URI.create(requestUrl(modelName)))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
}
class GeminiServiceImpl(
val timeoutSeconds: Int = 10,
val defaultModelName: String = GeminiServiceImpl.defaultModel
) extends ExternalTextGenerationServiceImpl {
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
private def requestDictionary(
inputText: String,
partialCompletion: Option[String]
): Map[String, Any] =
Map(
"contents" -> GeminiServiceImpl.contentsFor(inputText, partialCompletion)
)
override def makeRequest(
inputText: String,
partialCompletion: Option[String]
): HttpRequest =
GeminiServiceImpl
.baseRequest(modelName = defaultModelName, timeoutSeconds = timeoutSeconds)
.POST(
BodyPublishers.ofString(
Serialization.write(
requestDictionary(
inputText = inputText,
partialCompletion = partialCompletion
)
)
)
)
.build()
private class GeminiStringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
) extends Consumer[String] {
private val json = new Json(DefaultFormats)
// Gemini doesn't provide a stream ID in the same way as OpenAI/Claude,
// so we generate one based on the request
private val streamId = java.util.UUID.randomUUID().toString
override def accept(t: String): Unit = {
val parsedJson =
try
json.parse(t)
catch {
case _: org.json4s.ParserUtil.ParseException =>
// Skip malformed JSON chunks
return
}
// Check for errors first
(parsedJson \ "error") match {
case JObject(errorFields) if errorFields.nonEmpty =>
val errorMessage = (parsedJson \ "error" \ "message").extractOpt[String].getOrElse("Unknown error")
println(s"Gemini API error: $errorMessage")
return
case _ => // No error, continue processing
}
// Extract candidates array
val candidates = (parsedJson \ "candidates") match {
case JArray(arr) => arr
case _ => return // No candidates in this chunk
}
if candidates.isEmpty then return
val firstCandidate = candidates.head.asInstanceOf[JObject]
// Check finish reason
val finishReason = (firstCandidate \ "finishReason") match {
case JString(reason) => Some(reason)
case _ => None
}
val completed = finishReason.exists(r => r == "STOP" || r == "MAX_TOKENS" || r == "SAFETY")
// Extract text content
val textContent = for {
content <- (firstCandidate \ "content").toOption
parts <- (content \ "parts") match {
case JArray(arr) => Some(arr)
case _ => None
}
if parts.nonEmpty
firstPart = parts.head.asInstanceOf[JObject]
text <- (firstPart \ "text") match {
case JString(s) => Some(s)
case _ => None
}
} yield text
textContent match {
case Some(text) =>
streamingConsumer.accept(
StreamingTextResults(
streamId = streamId,
value = text,
completed = completed
)
)
case None =>
// No text content but might be completed
if completed then
streamingConsumer.accept(
StreamingTextResults(
streamId = streamId,
value = "",
completed = true
)
)
}
}
}
override def stringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
): Consumer[String] = new GeminiStringConsumer(streamingConsumer)
// Gemini rate limit headers are different from OpenAI/Anthropic
// For now, return None as rate limit tracking isn't critical
override def rateLimitsFrom(
headers: Map[String, Vector[String]]
): Option[RateLimits] = None
}
@@ -19,14 +19,24 @@ object OpenAIChatCompletionsServiceImpl {
private val baseURL = new URL("https://api.openai.com/v1/chat/completions")
private val temperature: Double = 1.0
// Non-reasoning models have -mini or -nano suffix; all others support reasoning_effort
private val nonReasoningSuffixes = Set("-mini", "-nano")
private def isReasoningModel(modelName: String): Boolean =
!nonReasoningSuffixes.exists(suffix => modelName.contains(suffix))
private def dictionaryFor(
modelName: String
): Map[String, Any] = Map(
"model" -> modelName,
"temperature" -> temperature,
"stream" -> true,
"reasoning_effort" -> "none"
)
): Map[String, Any] = {
val baseParams = Map(
"model" -> modelName,
"temperature" -> temperature,
"stream" -> true
)
// Only include reasoning_effort for reasoning models (o1, o3, etc.)
if isReasoningModel(modelName) then baseParams + ("reasoning_effort" -> "none")
else baseParams
}
private def messageVector(
inputText: String,
@@ -52,11 +52,17 @@ class OkHttpSseListener(messageDataConsumer: Consumer[String]) extends EventSour
response: Response
): Unit =
if !future.isDone then {
val errorMessage = if t != null then t.getMessage else "unknown error"
if response != null then {
println(s"SSE failure with response code ${response.code()}: ${t.getMessage}")
println(s"SSE failure with response code ${response.code()}: $errorMessage")
} else {
println(s"SSE failure: ${t.getMessage}")
println(s"SSE failure: $errorMessage")
}
val _ = future.completeExceptionally(t)
// OkHttp can pass null throwable for HTTP error responses
val exception =
if t != null then t
else
new RuntimeException(s"SSE failed with response code ${Option(response).map(_.code()).getOrElse("unknown")}")
val _ = future.completeExceptionally(exception)
}
}
@@ -28,6 +28,7 @@ scala_binary(
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:s3_utils",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
+5 -9
View File
@@ -12,7 +12,7 @@ import net.eagle0.eagle.api.auth.auth.AuthGrpc
import net.eagle0.eagle.api.eagle.EagleGrpc
import net.eagle0.eagle.auth.{JwtService, OAuthService, OAuthServiceImpl, UserService, UserServiceImpl}
import net.eagle0.eagle.service.*
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, S3Utils, SaveDirectory}
object Main {
import ServerSetupHelpers.*
@@ -25,8 +25,6 @@ object Main {
)
private val defaultShardokInterfaceRemoteAddress = "eagle0.net:443"
private val gptModelNameKey = Symbol("gptModelName")
private val serverBaseUrlKey = Symbol("serverBaseUrl")
private val defaultServerBaseUrl = "https://prod.eagle0.net"
@@ -49,11 +47,6 @@ object Main {
map ++ Map(shardokInterfaceRemoteAddressKey -> value),
tail
)
case "--gpt-model-name" +: value +: tail =>
nextOption(
map ++ Map(gptModelNameKey -> value),
tail
)
case "--server-base-url" +: value +: tail =>
nextOption(
map ++ Map(serverBaseUrlKey -> value),
@@ -106,6 +99,10 @@ object Main {
// Register shutdown hook to flush pending S3 saves on graceful shutdown
AsyncS3Persister.registerShutdownHook()
// Eagerly initialize S3 client to avoid first-connection latency
// (AWS SDK performs lazy initialization that can add 5+ seconds to first request)
S3Utils.warmup()
val options = parsedOptions(args)
SimpleTimedLogger.printLogger.logLine(s"Options: $options")
@@ -133,7 +130,6 @@ object Main {
val gamesManager = newGamesManager(
shardokInterfaceAddress = shardokInterfaceAddress,
gptModelName = options(gptModelNameKey),
securityConfig = shardokSecurityConfig
)
gamesManager.begin()
@@ -932,7 +932,7 @@ object MidGameAIClient {
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = ReconSelected(
actingHeroId = ac.availableHeroIds.maxBy(hid => gameState.heroes(hid).vigor),
actingHeroId = ac.availableHeroIds.minBy(hid => HeroUtils.fatigue(gameState.heroes(hid))),
targetProvinceId = targetProvinceId
),
reason = "selectedReconCommand"
@@ -207,10 +207,12 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero:event_for_hero_backstory_trait",
],
)
@@ -57,12 +57,11 @@ case class ChronicleUpdatePromptGenerator(
private def introString(currentDate: Date): String = {
val intro =
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for
|control of the kingdom. Write a $chronicleWordCount-word chronicle of the state of the war, as told by an
|in-universe chronicler, who writes in the style and format of $chroniclerStyle (but without reproducing any
|copyrighted material), as of ${GeneratorUtilities
.monthNames(
currentDate.month.value
)} of the Year of the Realm ${currentDate.year}.""".stripMargin
|control of the kingdom. Write a chronicle of the state of the war, as told by an in-universe chronicler, who
|writes in the style and format of $chroniclerStyle (but without reproducing any copyrighted material), as of
|${GeneratorUtilities.monthNames(currentDate.month.value)} of the Year of the Realm ${currentDate.year}.
|IMPORTANT: Keep your response to approximately $chronicleWordCount words (about 1.5 pages). Be concise and
|focus only on the most significant events.""".stripMargin
.replaceAll("\n", " ")
val styleGuidance =
@@ -197,6 +196,8 @@ case class ChronicleUpdatePromptGenerator(
|---
|THIRD SECTION
|$factionText
|---
|REMINDER: Keep the chronicle to approximately $chronicleWordCount words. Be selective about which events to include - focus on the 2-3 most significant developments rather than cataloguing everything.
|""".stripMargin
}
}
@@ -3,6 +3,7 @@ package net.eagle0.eagle.library.actions.llm_prompt_generators
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult}
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.*
import net.eagle0.eagle.HeroId
case class HandleCapturedHeroPleaPromptGenerator(
@@ -24,6 +25,75 @@ case class HandleCapturedHeroPleaPromptGenerator(
private val actingFactionLeader =
gameState.heroes(actingFaction.factionHeadId)
private def relationshipContext(
capturedHeroName: String,
actingFactionLeaderName: String
): String = {
val possP =
HeroDescriptionGenerator.possessivePronoun(capturedHero.pronounGender)
// Check if captured hero was ever in the acting faction (betrayal/defection)
val wasInActingFaction = capturedHero.backstoryEvents.exists {
case e: JoinedByInvitationBackstoryEvent =>
e.previousFactionId == plea.actingFactionId
case e: HeroDepartedBackstoryEvent =>
e.departedFromFactionId == plea.actingFactionId
case _ => false
}
val betrayalText =
if wasInActingFaction then
s"$capturedHeroName once served in ${actingFaction.name} before leaving. $actingFactionLeaderName may view $possP departure as a betrayal."
else ""
// Check if previously captured by the same faction
val previousCaptureByActingFaction = capturedHero.backstoryEvents.exists {
case e: CapturedHeroImprisonedBackstoryEvent =>
e.capturingFactionId == plea.actingFactionId
case e: CapturedHeroExiledBackstoryEvent =>
e.capturingFactionId == plea.actingFactionId
case e: CapturedHeroRecruitedBackstoryEvent =>
e.capturingFactionId == plea.actingFactionId
case e: CapturedHeroReturnedBackstoryEvent =>
e.capturingFactionId == plea.actingFactionId
case _ => false
}
val previousCaptureText =
if previousCaptureByActingFaction then
s"This is not the first time $capturedHeroName has been captured by ${actingFaction.name}."
else ""
// Check for prior battles against the acting faction
val priorBattlesAgainstActingFaction = capturedHero.backstoryEvents.count {
case e: FoughtInBattleBackstoryEvent =>
e.enemyFactionIds.contains(plea.actingFactionId)
case _ => false
}
val priorBattlesText =
if priorBattlesAgainstActingFaction > 2 then
s"$capturedHeroName has fought against ${actingFaction.name} many times before. There is history between them."
else if priorBattlesAgainstActingFaction > 0 then
s"$capturedHeroName has fought against ${actingFaction.name} before."
else ""
Vector(
betrayalText,
previousCaptureText,
priorBattlesText
).filter(_.nonEmpty).mkString(" ")
}
private def personalityGuidance(capturedHeroName: String): String = {
val words = capturedHero.personalityWords
if words.isEmpty then ""
else {
val wordsText = GeneratorUtilities.conjunction(words).getOrElse("")
s"$capturedHeroName is $wordsText - the message should reflect this personality."
}
}
def generate: TextGenerationResult = for {
capturedHeroDescription <- HeroDescriptionGenerator.description(
heroId = plea.capturedHeroId,
@@ -70,6 +140,10 @@ case class HandleCapturedHeroPleaPromptGenerator(
val executedHeroText =
s"$actingFactionLeaderName has already executed $executedHeroNames."
val relationshipText = relationshipContext(capturedHeroName, actingFactionLeaderName)
val personalityText = personalityGuidance(capturedHeroName)
val relationshipBlock = if relationshipText.nonEmpty then s"\n$relationshipText\n" else ""
val inputText =
s"""
|$setup
@@ -80,12 +154,14 @@ case class HandleCapturedHeroPleaPromptGenerator(
|$imprisonedHeroText
|$exiledHeroText
|$executedHeroText
|
|$relationshipBlock
|${subjP.capitalize} $isV undecided whether $subjP would join $actingFactionLeaderName, if asked.
|
|$capturedHeroDescription
|
|Write a message $capturedHeroName might say to $actingFactionLeaderName.
|Write a message $capturedHeroName might say to $actingFactionLeaderName. $personalityText
|The message might be defiant, pleading, negotiating, curious, resigned, bitter, hopeful, or sardonic depending on the character.
|Avoid clichéd phrases about swift death or killing.
|
|${GeneratorUtilities.limitations}""".stripMargin
@@ -3438,7 +3438,7 @@ scala_setting_library(
name = "chronicle_word_count",
setting_name = "ChronicleWordCount",
setting_type = "Int",
setting_value = "200",
setting_value = "400",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
@@ -3563,3 +3563,55 @@ scala_setting_library(
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "llm_provider",
setting_name = "LlmProvider",
setting_type = "String",
setting_value = "openai",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "openai_model_name",
setting_name = "OpenAiModelName",
setting_type = "String",
setting_value = "gpt-5-mini",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "claude_model_name",
setting_name = "ClaudeModelName",
setting_type = "String",
setting_value = "claude-sonnet-4-20250514",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "gemini_model_name",
setting_name = "GeminiModelName",
setting_type = "String",
setting_value = "gemini-2.5-flash-lite",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
@@ -15,3 +15,11 @@ scala_library(
"//visibility:public",
],
)
scala_library(
name = "string_setting",
srcs = ["StringSetting.scala"],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,6 @@
package net.eagle0.eagle.library.settings.base
class StringSetting(private var _stringValue: String) {
def stringValue: String = _stringValue
def setStringValue(newValue: String): Unit = _stringValue = newValue
}
@@ -77,6 +77,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:break_alliance_wisdom_xp",
"//src/main/scala/net/eagle0/eagle/library/settings:champion_training_bonus",
"//src/main/scala/net/eagle0/eagle/library/settings:chronicle_word_count",
"//src/main/scala/net/eagle0/eagle/library/settings:claude_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:control_weather_blizzard_duration_months",
"//src/main/scala/net/eagle0/eagle/library/settings:control_weather_drought_duration_months",
"//src/main/scala/net/eagle0/eagle/library/settings:control_weather_vigor_delta",
@@ -117,6 +118,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:food_per_province_held_back",
"//src/main/scala/net/eagle0/eagle/library/settings:free_hero_move_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:freed_prisoner_loyalty",
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:gift_province_count_exponent",
"//src/main/scala/net/eagle0/eagle/library/settings:gold_cost_for_divine",
"//src/main/scala/net/eagle0/eagle/library/settings:gold_increase_per_economy",
@@ -138,6 +140,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:jailbreak_success_base_chance",
"//src/main/scala/net/eagle0/eagle/library/settings:jailbreak_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:jailbreak_wisdom_xp",
"//src/main/scala/net/eagle0/eagle/library/settings:llm_provider",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_decrease_per_discordance",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_discordance_threshold",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
@@ -223,6 +226,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:months_recon_considered_recent",
"//src/main/scala/net/eagle0/eagle/library/settings:new_hero_chance",
"//src/main/scala/net/eagle0/eagle/library/settings:new_round_vigor_gain",
"//src/main/scala/net/eagle0/eagle/library/settings:openai_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:over_hero_cap_loyalty_delta",
"//src/main/scala/net/eagle0/eagle/library/settings:over_resource_limit_loss",
"//src/main/scala/net/eagle0/eagle/library/settings:per_development_resource_limit",
@@ -316,5 +320,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
"//src/main/scala/net/eagle0/eagle/library/settings/base:double_setting",
"//src/main/scala/net/eagle0/eagle/library/settings/base:int_setting",
"//src/main/scala/net/eagle0/eagle/library/settings/base:string_setting",
],
)
@@ -3,6 +3,7 @@ package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.ofType
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
@@ -43,8 +44,8 @@ object AllianceOfferCommandSelector {
selected = DiplomacySelected(
selectedOption = DiplomacyOptionType.Alliance,
targetFactionId = targetFactionId,
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).maxBy { hid =>
gameState.heroes(hid).vigor
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).minBy { hid =>
HeroUtils.fatigue(gameState.heroes(hid))
}
),
reason = "chosenAllianceWithFactionCommand"
@@ -12,6 +12,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
@@ -468,6 +469,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
@@ -657,7 +657,7 @@ object CommandChoiceHelpers {
available = availableCommand,
selected = {
val selectedHeroId = availableCommand.availableHeroIds
.maxBy(hid => gameState.heroes(hid).vigor)
.minBy(hid => HeroUtils.fatigue(gameState.heroes(hid)))
HandleRiotCrackDownSelected(
heroId = selectedHeroId,
battalionId = availableCommand.battalionIdsAvailable
@@ -3,6 +3,7 @@ package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.ofType
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
@@ -43,8 +44,8 @@ object TruceOfferCommandSelector {
selected = DiplomacySelected(
selectedOption = DiplomacyOptionType.Truce,
targetFactionId = targetFactionId,
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).maxBy { hid =>
gameState.heroes(hid).vigor
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).minBy { hid =>
HeroUtils.fatigue(gameState.heroes(hid))
}
),
reason = "chosenTruceWithFactionCommand"
@@ -31,6 +31,12 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/admin:game_admin_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:gemini_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
"//src/main/scala/net/eagle0/eagle/library:engine",
@@ -43,6 +49,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
"@maven//:com_thesamet_scalapb_scalapb_json4s_3",
"@maven//:io_sentry_sentry",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
@@ -215,6 +222,7 @@ scala_library(
":llm_resolver",
":llm_update_queuing_proxy",
":llm_update_receiver",
":missing_hero_name_recovery",
":persisted_history",
":post_results",
":sync_response_observer",
@@ -300,6 +308,24 @@ scala_library(
],
)
scala_library(
name = "missing_hero_name_recovery",
srcs = ["MissingHeroNameRecovery.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
"//src/test/scala/net/eagle0/eagle/service:__subpackages__",
],
deps = [
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
scala_library(
name = "llm_resolver",
srcs = ["LlmResolver.scala"],
@@ -308,11 +334,12 @@ scala_library(
],
deps = [
":llm_update_queuing_proxy",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:gemini_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
@@ -350,6 +377,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:swear_brotherhood_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_offer_message_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_resolution_message_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/settings:claude_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:llm_provider",
"//src/main/scala/net/eagle0/eagle/library/settings:openai_model_name",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:generated_text_request_converter",
"@maven//:io_sentry_sentry",
@@ -363,8 +394,8 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/service:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
],
)
@@ -376,8 +407,8 @@ scala_library(
],
deps = [
":llm_update_receiver",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
],
)
@@ -4,10 +4,19 @@ import java.io.{ByteArrayOutputStream, File}
import java.nio.file.Files
import java.util.zip.{ZipEntry, ZipOutputStream}
import scala.concurrent.Future
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}
import com.google.protobuf.ByteString
import io.grpc.stub.StreamObserver
import io.sentry.Sentry
import net.eagle0.common.llm_integration.{
ClaudeServiceImpl,
ExternalTextGenerationCaller,
ExternalTextGenerationServiceImpl,
GeminiServiceImpl,
OpenAIChatCompletionsServiceImpl
}
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.*
import net.eagle0.eagle.admin.game_admin.*
@@ -281,13 +290,100 @@ class GameAdminServiceImpl(
)
}
private implicit val ec: ExecutionContext = ExecutionContext.global
private val llmSettingKeys = Set("LlmProvider", "OpenAiModelName", "ClaudeModelName", "GeminiModelName")
private def validateLlmSettings(provider: String, modelName: String): Future[Either[String, Unit]] = {
// Wrap service creation in Try to catch API key initialization errors
val testServiceResult: Try[ExternalTextGenerationServiceImpl] = Try {
provider.toLowerCase match {
case "openai" => new OpenAIChatCompletionsServiceImpl(defaultModelName = modelName)
case "claude" => new ClaudeServiceImpl(defaultModelName = modelName)
case "gemini" => new GeminiServiceImpl(defaultModelName = modelName)
case _ => throw new IllegalArgumentException(s"Unknown provider: $provider")
}
}
testServiceResult match {
case Failure(e) =>
// Log error including to Sentry - likely missing API key
val errorMessage = e match {
case _: ExceptionInInitializerError =>
val cause = Option(e.getCause).map(_.getMessage).getOrElse("unknown cause")
s"Failed to initialize $provider provider (missing API key?): $cause"
case _ =>
s"Failed to create $provider service: ${e.getMessage}"
}
SimpleTimedLogger.printLogger.logLine(s"LLM validation error: $errorMessage")
Sentry.captureException(e)
Future.successful(Left(errorMessage))
case Success(testService) =>
// Create a caller and make a minimal test request
val testCaller = new ExternalTextGenerationCaller(serviceImpl = testService)
testCaller
.streamCompletion(
inputText = "Say OK",
partialCompletion = None,
streamingConsumer = _ => () // Discard all output
)
.map(_ => Right(()))
.recover { case e: Exception => Left(s"Validation failed: ${e.getMessage}") }
}
}
override def addSettings(
request: AddSettingsRequest
): Future[AddSettingsResponse] = {
gamesManager.addSettings(
request.settings.map(kv => kv.key -> kv.value).toVector
)
Future.successful(AddSettingsResponse())
val settingsMap = request.settings.map(kv => kv.key -> kv.value).toMap
val settingsVector = settingsMap.toVector
// Check if any LLM settings are being changed
val llmSettingsChanged = settingsMap.keys.exists(llmSettingKeys.contains)
if llmSettingsChanged then {
// Get the effective values (new value if provided, else current value)
val currentSettings = SettingsLoader.getAllSettings.map(s => s.name -> s.currentValue).toMap
val provider = settingsMap.getOrElse("LlmProvider", currentSettings.getOrElse("LlmProvider", "openai"))
val modelName = provider.toLowerCase match {
case "openai" =>
settingsMap.getOrElse("OpenAiModelName", currentSettings.getOrElse("OpenAiModelName", "gpt-5-mini"))
case "claude" =>
settingsMap.getOrElse(
"ClaudeModelName",
currentSettings.getOrElse("ClaudeModelName", "claude-sonnet-4-20250514")
)
case "gemini" =>
settingsMap.getOrElse(
"GeminiModelName",
currentSettings.getOrElse("GeminiModelName", "gemini-2.5-flash-lite")
)
case _ => ""
}
// Validate before applying
validateLlmSettings(provider, modelName).map {
case Right(()) =>
// Validation passed, apply settings
gamesManager.addSettings(settingsVector)
// Recreate LLM callers with new settings
gamesManager.recreateLlmCallers()
SimpleTimedLogger.printLogger.logLine(s"LLM settings updated: provider=$provider, model=$modelName")
AddSettingsResponse(success = true)
case Left(errorMessage) =>
// Validation failed, don't apply settings
SimpleTimedLogger.printLogger.logLine(s"LLM settings validation failed: $errorMessage")
AddSettingsResponse(success = false, errorMessage = errorMessage)
}
} else {
// No LLM settings, just apply normally
gamesManager.addSettings(settingsVector)
Future.successful(AddSettingsResponse(success = true))
}
end if
}
override def convertAiToHuman(
@@ -14,9 +14,7 @@ import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{Shardok
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.client_text.{ClientTextStoreImpl, PregeneratedClientTextStore, UnrequestedClientText}
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.internal.running_games.{RunningGame, RunningGames}
import net.eagle0.eagle.internal.shardok_battle as sb_proto
import net.eagle0.eagle.library.*
@@ -26,6 +24,8 @@ import net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.EagleRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedHeroName
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
import net.eagle0.eagle.model.proto_converters.shardok_battle.ShardokBattleConverter
@@ -122,7 +122,6 @@ object GamesManager {
persister: Persister,
gameCreation: NewGameCreation,
gamePersisterCreation: GamePersisterCreation,
gptModelName: String,
hexMaps: Map[String, HexMap]
): GamesManager =
// Lazy loading: Don't load any games at startup.
@@ -141,7 +140,6 @@ object GamesManager {
persister = persister,
gameCreation = gameCreation,
gamePersisterCreation = gamePersisterCreation,
gptModelName = gptModelName,
pregeneratedClientText = pregeneratedTexts,
hexMaps = hexMaps
)
@@ -168,20 +166,26 @@ object GamesManager {
userToFid: Map[String, FactionId],
pregeneratedHeroes: Vector[HeroWithName]
): GameController = {
val clientTextStore = ClientTextStoreImpl
val loadedTextStore = ClientTextStoreImpl
.loaded(
pregenerated = pregeneratedTexts,
persister = gamePersister
)
.get
val existingNames = (history.last.resultingState.killedHeroes ++
// Check all heroes' nameTextIds and recover any missing name requests
val allHeroes = (history.last.resultingState.killedHeroes ++
history.last.resultingState.heroes).values
.map(_.nameTextId)
.map { nameTextId =>
clientTextStore.getText(nameTextId).get
}
.toVector
val recoveryResult = MissingHeroNameRecovery.recoverMissingHeroNames(
clientTextStore = loadedTextStore,
heroes = allHeroes,
gameId = gameId,
factionIds = history.last.resultingState.factions.keys.toVector
)
val clientTextStore = recoveryResult.clientTextStore
val existingNames = recoveryResult.resolvedNames
val pregenHeroes =
pregeneratedHeroes.filterNot(heroWithName => existingNames.contains(heroWithName.name))
@@ -249,7 +253,6 @@ class GamesManager(
persister: Persister,
gameCreation: NewGameCreation,
gamePersisterCreation: GamePersisterCreation,
gptModelName: String,
pregeneratedClientText: PregeneratedClientTextStore,
val hexMaps: Map[String, HexMap]
) extends BattleUpdateReceiver
@@ -267,13 +270,18 @@ class GamesManager(
val shardokClient =
new ShardokInterfaceGrpcClient(shardokInternalInterface, this)
private val unrequestedTextHandler = new UnrequestedTextHandler(
llmResolver = new LlmResolver(
updateReceiver = new LlmUpdateQueuingProxy(this),
gptModelName = gptModelName
)
private val llmResolver = new LlmResolver(
updateReceiver = new LlmUpdateQueuingProxy(this),
isGameValid = gameId => gameControllerInfos.contains(gameId)
)
private val unrequestedTextHandler = new UnrequestedTextHandler(
llmResolver = llmResolver
)
def recreateLlmCallers(): Unit =
llmResolver.recreateLlmCallers()
def getHexMap(mapName: String): HexMap = hexMaps(mapName)
def begin(): Unit = this.synchronized {
@@ -545,10 +553,11 @@ class GamesManager(
override def receiveStreamingLlmResponses(
streamId: String,
llmResponses: Vector[LLMResponse],
llmRequest: GeneratedTextRequestT,
responseTexts: Vector[String],
completed: Boolean
): Unit = this.synchronized {
val gameId = llmResponses.head.llmRequest.get.eagleGameId
val gameId = llmRequest.eagleGameId
// Game may have been deleted while LLM request was in flight (e.g., warmup games)
gameControllerInfos.get(gameId) match {
@@ -561,7 +570,7 @@ class GamesManager(
case Some(controllerInfo) =>
synchronizedHandlePostResults(
controllerInfo.controller
.postStreamingLlmUpdates(streamId, llmResponses, completed)
.postStreamingLlmUpdates(streamId, llmRequest.requestId, responseTexts, completed)
).postResults
if completed then {
@@ -573,7 +582,7 @@ class GamesManager(
}
override def receiveStreamingLlmFailure(
llmRequest: GeneratedTextRequest
llmRequest: GeneratedTextRequestT
): Unit = this.synchronized {
val gameId = llmRequest.eagleGameId
@@ -587,12 +596,12 @@ class GamesManager(
case Some(controllerInfo) =>
synchronizedHandlePostResults(
controllerInfo.controller
.postStreamingLlmFailure(llmRequest)
.postStreamingLlmFailure(llmRequest.requestId)
).postResults
}
}
override def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId] =
override def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId] =
// Game may have been deleted while LLM request was in flight (e.g., warmup games)
gameControllerInfos.get(llmRequest.eagleGameId) match {
case None => Vector.empty
@@ -9,6 +9,7 @@ import net.eagle0.common.{FunctionalRandom, RandomState, SimpleTimedLogger}
import net.eagle0.common.llm_integration.{
ClaudeServiceImpl,
ExternalTextGenerationCaller,
GeminiServiceImpl,
OpenAIChatCompletionsServiceImpl
}
import net.eagle0.eagle.client_text.{
@@ -19,7 +20,6 @@ import net.eagle0.eagle.client_text.{
TextGenerationResult,
TextGenerationSuccess
}
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.library.actions.llm_prompt_generators.{
AllianceOfferMessagePromptGenerator,
AllianceResolutionMessagePromptGenerator,
@@ -56,6 +56,7 @@ import net.eagle0.eagle.library.actions.llm_prompt_generators.{
TruceOfferMessagePromptGenerator,
TruceResolutionMessagePromptGenerator
}
import net.eagle0.eagle.library.settings.{ClaudeModelName, GeminiModelName, LlmProvider, OpenAiModelName}
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.generated_text_request.{
FixedHeroName,
@@ -65,12 +66,14 @@ import net.eagle0.eagle.model.action_result.generated_text_request.{
}
import net.eagle0.eagle.model.proto_converters.GeneratedTextRequestConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.GameId
sealed trait LlmResolverResult
case class LlmResolverDependencyNotSatisfied(notSatisfiedTextId: String) extends LlmResolverResult
case object LlmResolverTooManyRequestsInFlight extends LlmResolverResult
case object LlmResolverBypassed extends LlmResolverResult
case class LlmResolverRequested(future: Future[?]) extends LlmResolverResult
case object LlmResolverGameDeleted extends LlmResolverResult
object LlmResolver {
case class LlmRequestWithGameState(
@@ -80,30 +83,55 @@ object LlmResolver {
)
}
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, gptModelName: String) {
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId => Boolean) {
import LlmResolver.LlmRequestWithGameState
implicit val ec: ExecutionContext = ExecutionContext.global
private def newChatGptServiceImpl = new OpenAIChatCompletionsServiceImpl(
defaultModelName = gptModelName
)
private def newClaudeServiceImpl = new ClaudeServiceImpl(
defaultModelName = "claude-sonnet-4-20250514"
)
private val callerCount = 2
private val chatGptCount = 2
private val claudeCount = 0
private def createLlmCallers(): Vector[ExternalTextGenerationCaller] =
LlmProvider.stringValue.toLowerCase match {
case "openai" =>
(1 to callerCount).map { _ =>
new ExternalTextGenerationCaller(
serviceImpl = new OpenAIChatCompletionsServiceImpl(defaultModelName = OpenAiModelName.stringValue)
)
}.toVector
case "claude" =>
(1 to callerCount).map { _ =>
new ExternalTextGenerationCaller(
serviceImpl = new ClaudeServiceImpl(defaultModelName = ClaudeModelName.stringValue)
)
}.toVector
case "gemini" =>
(1 to callerCount).map { _ =>
new ExternalTextGenerationCaller(
serviceImpl = new GeminiServiceImpl(defaultModelName = GeminiModelName.stringValue)
)
}.toVector
case provider =>
throw new IllegalArgumentException(s"Unknown LLM provider: $provider")
}
lazy private val llmCallers = ((1 to claudeCount).map { _ =>
new ExternalTextGenerationCaller(
serviceImpl = newClaudeServiceImpl
)
} ++ (1 to chatGptCount).map { _ =>
new ExternalTextGenerationCaller(
serviceImpl = newChatGptServiceImpl
)
}).toVector
@volatile private var _llmCallersInitialized: Boolean = false
@volatile private var _llmCallers: Vector[ExternalTextGenerationCaller] = Vector.empty
def llmCallers: Vector[ExternalTextGenerationCaller] = {
if !_llmCallersInitialized then {
synchronized {
if !_llmCallersInitialized then {
_llmCallers = createLlmCallers()
_llmCallersInitialized = true
}
}
}
_llmCallers
}
def recreateLlmCallers(): Unit = synchronized {
_llmCallers = createLlmCallers()
_llmCallersInitialized = true
}
lazy private val fileWriter =
new FileWriter(s"/tmp/llm_prompts_${System.currentTimeMillis()}.txt", true)
@@ -180,75 +208,75 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, gptModelName: String) {
maybePrompt: TextGenerationResult,
partialCompletion: Option[String]
) =>
// Convert to proto for boundaries that need it
val llmRequestProto = GeneratedTextRequestConverter.toProto(llmRequest)
(
llmRequest,
maybePrompt match {
case TextGenerationSuccess(prompt) =>
if prompt.contains("TextGeneration") || prompt
.contains("Option[") ||
prompt.contains("RandomState[")
then {
print(
s"Bad prompt for LLM request ${llmRequest.requestId}:\n$prompt\n"
)
}
llmCallers
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
.minByOption(_.getInProgressCount)
.map { chosenCaller =>
log(llmRequest, prompt)
LlmResolverRequested {
chosenCaller
.streamCompletion(
inputText = prompt,
partialCompletion = partialCompletion,
streamingConsumer = streamingResults =>
updateReceiver.receiveStreamingLlmResponse(
streamId = streamingResults.streamId,
llmResponse = LLMResponse(
llmRequest = Some(llmRequestProto),
responseText = streamingResults.value
),
completed = streamingResults.completed
)
)
.recover {
case ex =>
// Log the failure for debugging and send to Sentry
SimpleTimedLogger.printLogger.logLine(
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
)
ex.printStackTrace()
Sentry.captureException(ex)
// Handle failure by moving request back to unrequested state for retry
updateReceiver.receiveStreamingLlmFailure(
llmRequest = llmRequestProto
)
throw ex // Re-throw to maintain error semantics
}
}
// Check if game still exists before sending the request
// This prevents wasted LLM calls for games that were deleted (e.g., warmup games
// during blue-green deployments)
if !isGameValid(llmRequest.eagleGameId) then {
(llmRequest, LlmResolverGameDeleted)
} else {
(
llmRequest,
maybePrompt match {
case TextGenerationSuccess(prompt) =>
if prompt.contains("TextGeneration") || prompt
.contains("Option[") ||
prompt.contains("RandomState[")
then {
print(
s"Bad prompt for LLM request ${llmRequest.requestId}:\n$prompt\n"
)
}
.getOrElse(LlmResolverTooManyRequestsInFlight)
case TextGenerationDependencyInProgress(
notSatisfiedTextId,
_ /* partialText */
) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
case TextGenerationDependencyWaiting(notSatisfiedTextId) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
llmCallers
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
.minByOption(_.getInProgressCount)
.map { chosenCaller =>
log(llmRequest, prompt)
case TextGenerationDependencyUnknown(notSatisfiedTextId) =>
throw new EagleInternalException(
s"Unknown dependency for LLM request ${llmRequest.requestId}: $notSatisfiedTextId"
)
}
)
LlmResolverRequested {
chosenCaller
.streamCompletion(
inputText = prompt,
partialCompletion = partialCompletion,
streamingConsumer = streamingResults =>
updateReceiver.receiveStreamingLlmResponse(
streamId = streamingResults.streamId,
llmRequest = llmRequest,
responseText = streamingResults.value,
completed = streamingResults.completed
)
)
.recover {
case ex =>
// Log the failure for debugging and send to Sentry
SimpleTimedLogger.printLogger.logLine(
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
)
ex.printStackTrace()
Sentry.captureException(ex)
// Handle failure by moving request back to unrequested state for retry
updateReceiver.receiveStreamingLlmFailure(llmRequest)
throw ex // Re-throw to maintain error semantics
}
}
}
.getOrElse(LlmResolverTooManyRequestsInFlight)
case TextGenerationDependencyInProgress(
notSatisfiedTextId,
_ /* partialText */
) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
case TextGenerationDependencyWaiting(notSatisfiedTextId) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
case TextGenerationDependencyUnknown(notSatisfiedTextId) =>
throw new EagleInternalException(
s"Unknown dependency for LLM request ${llmRequest.requestId}: $notSatisfiedTextId"
)
}
)
}
}
}
@@ -1,6 +1,6 @@
package net.eagle0.eagle.service
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.FactionId
class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
@@ -8,11 +8,12 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
private sealed trait QueuedLlmUpdate
private case class StreamingLlmResponse(
streamId: String,
llmResponse: LLMResponse,
llmRequest: GeneratedTextRequestT,
responseText: String,
completed: Boolean
) extends QueuedLlmUpdate
private case class StreamingLlmFailure(
llmRequest: GeneratedTextRequest
llmRequest: GeneratedTextRequestT
) extends QueuedLlmUpdate
private val queue = new scala.collection.mutable.Queue[QueuedLlmUpdate]
@@ -21,20 +22,21 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
def receiveStreamingLlmResponse(
streamId: String,
llmResponse: LLMResponse,
llmRequest: GeneratedTextRequestT,
responseText: String,
completed: Boolean
): Unit = queue.synchronized {
queue.enqueue(StreamingLlmResponse(streamId, llmResponse, completed))
queue.enqueue(StreamingLlmResponse(streamId, llmRequest, responseText, completed))
queue.notifyAll()
}
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequest): Unit =
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequestT): Unit =
queue.synchronized {
queue.enqueue(StreamingLlmFailure(llmRequest))
queue.notifyAll()
}
def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId] =
def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId] =
destination.aiPlayers(llmRequest)
private class Consumer extends Runnable {
@@ -63,17 +65,18 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
case (streamId, messages) =>
destination.receiveStreamingLlmResponses(
streamId,
messages.map(_.llmResponse),
messages.last.completed
llmRequest = messages.head.llmRequest,
responseTexts = messages.map(_.responseText),
completed = messages.last.completed
)
}
} catch {
case e: Exception =>
val updateIds = updates.map {
case StreamingLlmResponse(streamId, llmResponse, _) =>
s"response:${llmResponse.llmRequest.map(_.id).getOrElse("unknown")}"
case StreamingLlmFailure(llmRequest) =>
s"failure:${llmRequest.id}"
case StreamingLlmResponse(_, llmRequest, _, _) =>
s"response:${llmRequest.requestId}"
case StreamingLlmFailure(llmRequest) =>
s"failure:${llmRequest.requestId}"
}.mkString(", ")
System.err.println(
s"ERROR: LlmUpdateQueuingProxy consumer caught exception processing updates [$updateIds]: ${e.getMessage}"
@@ -1,15 +1,16 @@
package net.eagle0.eagle.service
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.FactionId
/** Receives LLM streaming updates. Uses Scala domain types instead of protos. */
trait LlmUpdateReceiver {
def receiveStreamingLlmResponses(
streamId: String,
llmResponses: Vector[LLMResponse],
llmRequest: GeneratedTextRequestT,
responseTexts: Vector[String],
completed: Boolean
): Unit
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequest): Unit
def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId]
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequestT): Unit
def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId]
}
@@ -0,0 +1,105 @@
package net.eagle0.eagle.service
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.{FactionId, GameId}
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationSuccess}
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedHeroName
import net.eagle0.eagle.model.state.hero.HeroT
/**
* Utility for recovering missing hero name text requests.
*
* When a hero exists in the game state but its name text is missing from the ClientTextStore, this utility can recreate
* the name generation request so the name gets generated.
*/
object MissingHeroNameRecovery {
/** Pattern for randomly generated hero names: "hn_<heroId>" */
private val heroNamePattern = """^hn_(\d+)$""".r
/**
* Result of recovering missing hero names.
*
* @param clientTextStore
* The updated text store with any missing name requests added
* @param resolvedNames
* Names that were successfully resolved (already in the store)
* @param recoveredCount
* Number of missing names that were recovered (requests added)
*/
case class RecoveryResult(
clientTextStore: ClientTextStore,
resolvedNames: Vector[String],
recoveredCount: Int
)
/**
* Checks heroes for missing name texts and recovers them by adding GeneratedHeroName requests.
*
* For each hero:
* - If the name text exists, it's added to resolvedNames
* - If the name text is missing and matches the "hn_X" format, a GeneratedHeroName request is created
* - If the name text is missing and doesn't match "hn_X" (e.g., "fhn_" pregenerated names), an exception is thrown
*
* @param clientTextStore
* The text store to check and update
* @param heroes
* Heroes to check for missing names
* @param gameId
* Game ID for the GeneratedHeroName request
* @param factionIds
* Faction IDs for text accessibility
* @return
* RecoveryResult with updated store, resolved names, and recovery count
*/
def recoverMissingHeroNames(
clientTextStore: ClientTextStore,
heroes: Iterable[HeroT],
gameId: GameId,
factionIds: Vector[FactionId]
): RecoveryResult = {
val (updatedStore, names, recovered) =
heroes.foldLeft((clientTextStore, Vector[String](), 0)) {
case ((cts, names, recoveredCount), hero) =>
cts.getText(hero.nameTextId) match {
case TextGenerationSuccess(name) =>
(cts, names :+ name, recoveredCount)
case other =>
// Text is missing or incomplete - try to self-heal for "hn_X" format names
hero.nameTextId match {
case heroNamePattern(heroIdStr) =>
val heroId = heroIdStr.toInt
SimpleTimedLogger.printLogger.logLine(
s"WARNING: Self-healing missing hero name for hero $heroId (textId=${hero.nameTextId}). " +
s"Text status was: ${other.getClass.getSimpleName}. " +
s"Adding GeneratedHeroName request to unrequested texts."
)
// Create a new name generation request
val nameRequest = GeneratedHeroName(
requestId = hero.nameTextId,
eagleGameId = gameId,
gender = hero.pronounGender
)
val updatedCts = cts.withAddedTextRequest(
id = hero.nameTextId,
accessibleTo = factionIds,
llmRequest = nameRequest,
requestedAfterHistoryCount = 1 // Use 1 since this is recovery
)
// Don't add to names - it will be generated when UnrequestedTextHandler runs
(updatedCts, names, recoveredCount + 1)
case _ =>
// For non-hn_ format names (like fhn_ pregenerated), this is a real error
throw new EagleInternalException(
s"Text generation dependency unknown for hero ${hero.id}: ${hero.nameTextId}"
)
}
}
}
RecoveryResult(updatedStore, names, recovered)
}
}
@@ -56,7 +56,6 @@ object ServerSetupHelpers {
def newGamesManager(
shardokInterfaceAddress: String,
gptModelName: String,
securityConfig: ShardokSecurityConfig = ShardokSecurityConfig()
): GamesManager = {
implicit val ec = scala.concurrent.ExecutionContext.global
@@ -103,7 +102,6 @@ object ServerSetupHelpers {
persister = CompoundPersister(persisters),
gameCreation = FixedNewGameCreation,
gamePersisterCreation = LocalGamePersisterCreation,
gptModelName = gptModelName,
hexMaps = hexMaps
)
}
@@ -125,6 +125,10 @@ class UnrequestedTextHandler(llmResolver: LlmResolver) {
)
case LlmResolverTooManyRequestsInFlight =>
acc
case LlmResolverGameDeleted =>
// Game was deleted (e.g., warmup game during blue-green deployment)
// Just skip the request - it will be cleaned up when the game's data is deleted
acc
case LlmResolverDependencyNotSatisfied(unsatisfiedTextId) =>
acc.copy(stuckEntries =
acc.stuckEntries :+ StuckUnrequested(
@@ -205,6 +209,9 @@ class UnrequestedTextHandler(llmResolver: LlmResolver) {
s"Moving stalled incomplete text ${llmRequest.requestId} back to unrequested due to unsatisfied dependency"
)
cts.withMovedBackToUnrequested(llmRequest.requestId)
case (cts, (_, LlmResolverGameDeleted)) =>
// Game was deleted - just skip, texts will be cleaned up with the game
cts
case (cts, _) =>
cts
}
@@ -16,8 +16,6 @@ import net.eagle0.eagle.api.eagle.ShardokActionResultResponse.SingleShardokGameR
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.client_text.{ClientText, ClientTextStore, ClientTextStoreWithUpdate}
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.library.{EagleInternalException, Engine, EngineAndResults}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
import net.eagle0.eagle.library.util.CommandSelection
@@ -368,21 +366,37 @@ final case class GameController(
}
)
val newEngine = engine.withHistory(newHistory)
// Push Shardok updates to human clients
val updatedHumanClients = GameController.humanClientsAfterPostingResults(
humanClients = humanClients,
clientTextStore = clientTextStore,
visibilityExtensions = Vector.empty,
engine = newEngine,
fullHistory = newHistory
)
GameControllerWithPostResults(
gameController = copy(engine = engine.withHistory(newHistory), fullHistory = newHistory),
gameController = copy(
engine = newEngine,
fullHistory = newHistory,
humanClients = updatedHumanClients
),
postResults = PostResults(gameState = None, battles = Vector(), results = Vector())
)
}
private def postOneLlmResponse(
streamId: String,
llmResponse: LLMResponse,
requestId: String,
responseText: String,
completed: Boolean
): GameController =
this.clientTextStore
.withAppendedText(
id = llmResponse.llmRequest.get.id,
newText = llmResponse.responseText,
id = requestId,
newText = responseText,
complete = completed
) match {
case ClientTextStoreWithUpdate(cts, updatedText) =>
@@ -404,21 +418,18 @@ final case class GameController(
def postStreamingLlmUpdates(
streamId: String,
llmResponses: Vector[LLMResponse],
requestId: String,
responseTexts: Vector[String],
completed: Boolean
): GameControllerWithPostResults = {
val requestId = llmResponses.head.llmRequest.get.id
internalRequire(
llmResponses.forall(_.llmRequest.get.id == requestId),
"All LLM responses must have the same ID"
)
val controllerWithHandledStreams = llmResponses match {
internalRequire(responseTexts.nonEmpty, "responseTexts is empty")
val controllerWithHandledStreams = responseTexts match {
case h :+ t =>
h.foldLeft(this) {
case (innerC, llmResponse) =>
innerC.postOneLlmResponse(streamId, llmResponse, completed = false)
}.postOneLlmResponse(streamId, t, completed)
case _ => throw new EagleInternalException("llmResponses is empty")
case (innerC, responseText) =>
innerC.postOneLlmResponse(streamId, requestId, responseText, completed = false)
}.postOneLlmResponse(streamId, requestId, t, completed)
case _ => throw new EagleInternalException("responseTexts is empty")
}
val currentState = controllerWithHandledStreams.engine.currentState
@@ -433,10 +444,10 @@ final case class GameController(
}
def postStreamingLlmFailure(
llmRequest: GeneratedTextRequest
requestId: String
): GameControllerWithPostResults = {
val updatedClientTextStore =
clientTextStore.withMovedBackToUnrequested(llmRequest.id)
clientTextStore.withMovedBackToUnrequested(requestId)
val currentState = engine.currentState
GameControllerWithPostResults(
@@ -90,6 +90,7 @@ scala_library(
name = "s3_utils",
srcs = ["S3Utils.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -100,6 +101,7 @@ scala_library(
],
deps = [
":persistence_pkg",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/service/persistence/credentials",
"@maven//:org_reactivestreams_reactive_streams",
@@ -7,13 +7,19 @@ import scala.annotation.tailrec
import scala.jdk.CollectionConverters.*
import scala.util.{Failure, Try}
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.service.persistence
import net.eagle0.eagle.service.persistence.credentials.S3Credentials
import net.eagle0.eagle.GameId
import software.amazon.awssdk.core.async.AsyncRequestBody
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.{S3AsyncClient, S3Client}
import software.amazon.awssdk.services.s3.model.{GetObjectRequest, ListObjectsV2Request, PutObjectRequest}
import software.amazon.awssdk.services.s3.model.{
GetObjectRequest,
HeadBucketRequest,
ListObjectsV2Request,
PutObjectRequest
}
import software.amazon.awssdk.transfer.s3.model.UploadRequest
import software.amazon.awssdk.transfer.s3.S3TransferManager
@@ -41,6 +47,31 @@ object S3Utils {
)
.build
/**
* Eagerly initialize S3 clients during startup to avoid first-connection latency. The AWS SDK performs lazy
* initialization (profile loading, TLS handshake, etc.) which can add 5+ seconds to the first request.
*/
def warmup(): Unit =
if S3Credentials.isEnabled then {
SimpleTimedLogger.printLogger.logLine("Warming up S3 client...")
try {
// Force initialization of the lazy transferManager
val _ = transferManager
// Make a lightweight S3 call to complete TLS handshake and connection setup
val s3 = syncClient()
s3.headBucket(HeadBucketRequest.builder().bucket(mainBucketName).build())
SimpleTimedLogger.printLogger.logLine("S3 client warmup complete")
} catch {
case e: Exception =>
// Log but don't fail startup - S3 operations will retry as needed
SimpleTimedLogger.printLogger.logLine(
s"S3 warmup warning (non-fatal): ${e.getMessage}"
)
}
}
val runningGamesKeyPrefix = "eagle/save/"
def makeS3Prefix(gameId: GameId): String =
@@ -509,7 +509,6 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero/concrete",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
)
@@ -694,7 +693,6 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/test/scala/net/eagle0/common:proto_matchers",
"@maven//:org_scalamock_scalamock_3",
],
)
@@ -156,7 +156,7 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
case _ => fail("Text generation failed")
}
val expectedText =
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for control of the kingdom. Write a 250-word chronicle of the state of the war, as told by an in-universe chronicler, who writes in the style and format of a modern journalist (but without reproducing any copyrighted material), as of October of the Year of the Realm 343.
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for control of the kingdom. Write a chronicle of the state of the war, as told by an in-universe chronicler, who writes in the style and format of a modern journalist (but without reproducing any copyrighted material), as of October of the Year of the Realm 343. IMPORTANT: Keep your response to approximately 250 words (about 1.5 pages). Be concise and focus only on the most significant events.
|
|${MapDescription.mapDescription}
|
@@ -180,6 +180,8 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
|The Eagle's Faction, led by The Eagle, controls Gorania. The Eagle is a mysterious stranger from afar.
|The King's Loyalists, led by Bregos Fyar, controls Faluria. Bregos Fyar is the King of the realm.
|Vengeance, led by Bridget, controls Hakaria and Igaria. Bridget is an enigmatic mage.
|---
|REMINDER: Keep the chronicle to approximately 250 words. Be selective about which events to include - focus on the 2-3 most significant developments rather than cataloguing everything.
|""".stripMargin
generatedText shouldBe expectedText

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