Compare commits

..
58 Commits
Author SHA1 Message Date
admin d6e4a5798b storage 2025-06-23 20:57:28 -07:00
admin 6b39e0632a only build the presigner 2025-06-23 20:34:15 -07:00
admin 1a6ce2ef2d ubuntu version 2025-06-23 20:32:14 -07:00
admin 735013e39f set up buildbuddy.yaml 2025-06-23 20:30:20 -07:00
admin 0b3d02ddf9 add build buddy 2025-06-19 17:05:53 -07:00
adminandGitHub 69e147548f don't build the shardok server so aggressively (#4190) 2025-06-19 16:23:58 -07:00
adminandGitHub 72c6bb0122 start manifest generation (#4189)
* start manifest generation

* gazelle

* building this way too much
2025-06-19 15:52:51 -07:00
adminandGitHub cec62a6abb include a check (#4187)
* include a check

* and deploy

* use /Users/dancrosby/CodingProjects/github/eagle0

* only deploy on main
2025-06-19 15:28:46 -07:00
adminandGitHub f37b697444 ignore go files for mac history build (#4188)
* ignore go files for mac history build

* better

* whoops
2025-06-19 14:42:32 -07:00
adminandGitHub 736c72845d Build the installer as a Github Action (#4186)
* newer .net and some fixes

* add a github action for building the installer

* build on every PR

* fix handler
2025-06-19 11:15:38 -07:00
adminandGitHub fb3b8caf3b self updating installer (#4182)
* try a self updater

* cleanup

* make it a windows forms application

* weird

* window handle not yet created

* always release the semaphore

* use async and 8 download slots
2025-06-19 09:47:33 -07:00
adminandGitHub 36f104c828 More headshots (#4185)
* more

* deduplicator

* deduplicate names

* more

* more heroes

* more heroes

* working
2025-06-19 09:45:48 -07:00
adminandGitHub 467dfcebb9 fix the dupes (#4184)
* dupes

* lots of dupes
2025-06-19 06:57:27 -07:00
adminandGitHub 1193fb98b1 More headshots (#4183)
* more heroes

* more headshots
2025-06-18 21:32:28 -07:00
adminandGitHub e9aa8242b1 yet more heroes (#4181)
* some

* more

* a bunch more heroes

* another dupe
2025-06-18 17:22:43 -07:00
adminandGitHub 569d665626 try caching again (#4178)
* try caching again

* do the build

* also avoid hero generation
2025-06-18 05:31:14 -07:00
adminandGitHub 2242f53bce fix the scripts (#4180) 2025-06-18 05:29:54 -07:00
adminandGitHub 75effa4f39 remove the headshot fetch service from the server (#4179) 2025-06-17 20:57:29 -07:00
adminandGitHub 091a6ee3ad use HttpClient to fetch (#4174)
* use HttpClient to fetch

* not that

* try setting up the http client

* restored

* seems to be working
2025-06-17 20:45:02 -07:00
adminandGitHub e89e36b8b7 more heroes (#4175)
* use HttpClient to fetch

* not the c#

* not that

* not that

* whoops

* back to the main repo

* fixes to the imagechecker

* another name collision

* fix illegal characters

* another fix

* think that finally did it
2025-06-17 20:19:37 -07:00
adminandGitHub b69faa5b2d add a bazel cache (#4177)
* update the client downloader

* add a bazel cache
2025-06-17 19:03:18 -07:00
adminandGitHub d995b0c949 update the client downloader (#4176) 2025-06-17 18:17:15 -07:00
adminandGitHub 836c975a97 create a headshots pipeline (#4157)
* start the headshot reader

* put in placeholder image paths

* next stage with the generated heroes

* include the full description

* with adjectives

* more variety

* add the image checker

* fixes

* metadata updates

* pretty good

* gazelle

* don't need the tsvfixer

* discard font changes
2025-06-17 07:00:42 -07:00
adminandGitHub aafd622a81 Divine prompt generator fixes (#4172)
* handle prisoner description

* one more
2025-06-15 15:55:08 -07:00
adminandGitHub f4ef8a949d fix some prompt generation errors (#4173) 2025-06-15 15:42:47 -07:00
adminandGitHub e88b78525d dismiss vassal quest (#4171) 2025-06-14 19:02:44 -07:00
adminandGitHub 9d1c54cb81 missed these (#4170) 2025-06-14 18:26:20 -07:00
adminandGitHub 9e616e5c05 more placeholder text (#4169) 2025-06-14 18:25:02 -07:00
adminandGitHub 76717d660e fix missing text (#4168)
* fix missing text

* what
2025-06-14 17:59:48 -07:00
adminandGitHub ca9e3e664f messed up CustomBattleHandler (#4167)
* messed up CustomBattleHandler

* one more
2025-06-14 16:23:06 -07:00
adminandGitHub a0396c8bff missing some placeholder text (#4166) 2025-06-14 16:07:06 -07:00
adminandGitHub 257d8bed30 Remove Name field from HeroProto (#4164)
* it's a slog

* more slogging

* no longer needed

* confused

* dumb

* more fixes

* it builds

* update a bunch of them

* a couple more

* one failing

* bleh

* fix some extras

* fix the test
2025-06-13 16:57:45 -07:00
adminandGitHub f23ea0b10c search by NameTextId (#4165) 2025-06-13 08:23:38 -07:00
adminandGitHub 0d066b1ce6 exclude names without using HeroProto.Name (#4163) 2025-06-13 07:31:12 -07:00
adminandGitHub addc2c13fe remove dead RandomHeroGenerator code (#4162) 2025-06-13 07:05:52 -07:00
adminandGitHub e22d360410 no more usage of HeroProto.Name in llm prompt generators (#4161)
* remaining usages of Name in prompt generators

* complete the refactor
2025-06-13 06:53:43 -07:00
adminandGitHub d74fc82df7 Remove the Name field from HeroC and HeroT (#4160)
* add a verifier

* it builds

* fix a bunch

* tests pass

* more mismatch

* bad use of name

* remove checks

* fix one test
2025-06-12 21:18:12 -07:00
adminandGitHub f91f273539 remove Name from hero_view (#4159) 2025-06-12 19:36:45 -07:00
adminandGitHub 3f5c5e4a10 Use NameTextId in UnaffiliatedHeroBasics (#4158)
* populate UnaffiliatedHeroBasics nameTextId

* add NameTextId to UnaffiliatedHeroBasics

* replace the usage of UnaffiliatedHeroBasics.name
2025-06-12 19:20:57 -07:00
adminandGitHub dbf539ff15 taking a stab at the last references (#4156)
* taking a stab at the last references

* small refactor

* update the last one

* fixes

* refactor

* fix the templates
2025-06-11 07:18:31 -07:00
adminandGitHub 9875055787 a little cleanup (#4155) 2025-06-10 21:16:41 -07:00
adminandGitHub 7fbf1fb43c update table rows too (#4154) 2025-06-10 21:11:47 -07:00
adminandGitHub 36c92f7e91 fix the remaining notifications (#4153) 2025-06-10 20:56:13 -07:00
adminandGitHub d64c8064f6 Change some notifications (#4152)
* try refactoring a few

* another approach

* fix these two

* the rest of the ARNNotifcationGenerators
2025-06-10 20:53:36 -07:00
adminandGitHub 08414206ff More usages in CommandSelectors (#4151)
* fix unity client

* DefendCommandSelector

* IssueOrdersCommandSelector

* more refactors

* fix PleaseRecruitMe
2025-06-10 19:47:58 -07:00
adminandGitHub 65689dce38 fix unity client (#4150) 2025-06-10 19:25:44 -07:00
adminandGitHub 7684e4c218 change ApprehendOutlawCommandSelector to use NameTextId (#4149)
* change ApprehendOutlawCommandSelector to use NameTextId

* make it more general

* refactor more
2025-06-10 19:24:20 -07:00
adminandGitHub 935b9341cd include client info too (#4148) 2025-06-10 07:46:04 -07:00
adminandGitHub 59c555a297 add CLAUDE.md (#4147) 2025-06-10 07:34:57 -07:00
adminandGitHub 414537c617 new herodata file in lfs (#4146) 2025-06-08 07:47:38 -07:00
adminandGitHub 75a595e289 fix TextGenerationSuccess in DivineMessagePromptGenerator (#4144) 2025-06-08 07:37:29 -07:00
adminandGitHub 405cbf832f oops (#4143) 2025-06-06 21:30:04 -07:00
adminandGitHub d23f024a35 update the library generator (#4142)
* start updating the library generator

* loadedhero to its own package

* tests pass

* the library generator is working
2025-06-06 21:28:28 -07:00
adminandGitHub 8cfa6fc66e HeroGenerator now returns Hero model objects instead of HeroProtos (#4141)
* most of the conversion

* it builds

* fix the tests
2025-06-06 16:37:31 -07:00
adminandGitHub 02070d7b45 missing AllianceQuest (#4140) 2025-06-06 07:25:03 -07:00
adminandGitHub 1fd30e628e look into the order of the textid assignment (#4139) 2025-06-06 07:23:15 -07:00
adminandGitHub d9526bb880 put Random back into the initial dropdown (#4138) 2025-06-06 07:06:58 -07:00
adminandGitHub faa0ff7bb1 update to latest Claude model and use it alongside gpt (#4137) 2025-06-06 06:49:58 -07:00
204 changed files with 12639 additions and 1407 deletions
+4
View File
@@ -30,3 +30,7 @@ common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
common --tool_java_runtime_version=remotejdk_17
build --bes_results_url=https://app.buildbuddy.io/invocation/
build --bes_backend=grpcs://remote.buildbuddy.io
build --remote_header=x-buildbuddy-api-key=vxkxHhPAglBKl6yKhMy2
+1
View File
@@ -6,3 +6,4 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.herodata filter=lfs diff=lfs merge=lfs -text
+10 -4
View File
@@ -4,12 +4,14 @@ on:
push:
branches: [ "main" ]
paths:
- "src/main/go/**"
- "!src/main/go/net/eagle0/web_functions/name-generator/**"
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
pull_request:
paths:
- "src/main/go/**"
- "!src/main/go/net/eagle0/web_functions/name-generator/**"
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
permissions:
contents: read
@@ -19,6 +21,10 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: bazel-contrib/setup-bazel@0.15.0
with:
bazelisk-cache: true
disk-cache: true
- uses: actions/checkout@v4
with:
lfs: false
+81
View File
@@ -0,0 +1,81 @@
name: Installer Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
permissions:
contents: read
jobs:
build-installer:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
- name: Build installer
run: dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj -c Release -r win-x64 --self-contained true --output ./installer-output
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: eagle-installer
path: ./installer-output/EagleInstaller.exe
- name: Verify installer exists
if: success()
run: |
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
echo "ERROR: EagleInstaller.exe not found at expected location"
echo "Directory contents:"
ls -la ./installer-output/
exit 1
fi
echo "Installer found at correct location"
- name: Deploy installer
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
echo "Using absolute path: $INSTALLER_PATH"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Create installer manifest content
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
cat > /tmp/installer_manifest.txt << EOF
installer_version=1.0.0
installer_url=installer/EagleInstaller.exe
installer_sha256=$INSTALLER_SHA
EOF
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
+8 -14
View File
@@ -3,21 +3,15 @@ name: Mac History Editor Build
on:
push:
branches: [ "main" ]
paths-ignore:
- "src/main/cpp/**"
- "src/main/scala/**"
- "src/main/csharp/**"
- "src/test/cpp/**"
- "src/test/scala/**"
- "src/test/csharp/**"
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
pull_request:
paths-ignore:
- "src/main/cpp/**"
- "src/main/scala/**"
- "src/main/csharp/**"
- "src/test/cpp/**"
- "src/test/scala/**"
- "src/test/csharp/**"
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
permissions:
contents: read
+2
View File
@@ -6,10 +6,12 @@ on:
paths-ignore:
- "src/main/csharp/**"
- "src/test/csharp/**"
- "src/main/go/**"
pull_request:
paths-ignore:
- "src/main/csharp/**"
- "src/test/csharp/**"
- "src/main/go/**"
permissions:
contents: read
+11
View File
@@ -40,6 +40,17 @@ jobs:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN"
- name: Update unified manifest
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Download the Unity manifest that was just uploaded
wget -O /tmp/unity_manifest.txt https://eagle0-windows.sfo3.digitaloceanspaces.com/unity3d/eagle0WIN_manifest.txt
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
+123
View File
@@ -0,0 +1,123 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
## Architecture
**Three-Tier Game System:**
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle resolution
**Communication Flow:**
```
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
```
**Key Entry Points:**
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
**Protocol Buffer Architecture:**
- Extensive use of protobuf for type-safe communication
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
- Event sourcing pattern with immutable action history
## Essential Commands
### Building
```bash
# Build Eagle server (Scala strategic layer)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok server (C++ tactical layer)
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
# Build Unity/C# client
./scripts/build_protos.sh # Protocol buffer generation for Unity
./scripts/build_plugins.sh # Native plugins for all platforms
./scripts/build_windows_plugin.sh # Windows-specific plugin build
# Unity builds via CI: ci/github_actions/build_unity.sh
```
### Running Services
```bash
# Eagle server (port 40032)
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
# Or: ./scripts/eagle_run.sh
# Shardok server
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=opt
# Or: ./scripts/shardok_run.sh
```
### Testing
```bash
# Run all tests
bazel test //src/test/... //src/main/go/...
# Component-specific tests
bazel test //src/test/scala/... # Scala Eagle tests
bazel test //src/test/cpp/... # C++ Shardok tests
```
### Code Generation
```bash
bazel run gazelle # Update Go build files
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
```
## Language-Specific Patterns
**Scala (Strategic Layer):**
- Use `EngineImpl.scala` for core game logic modifications
- Follow event sourcing pattern - all changes through immutable actions
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
- LLM integration in `/common/llm_integration/` for narrative generation
**C++ (Tactical Layer):**
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
- Extensive unit testing with Google Test framework
**Protocol Buffers:**
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
- Maintain backward compatibility when modifying existing messages
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
- Seamless transition between strategic gameplay and hex-based tactical combat
**Go (Build Tools):**
- Build automation and code generation utilities
- AWS S3 integration for deployment artifacts
## Testing Strategy
- Comprehensive unit tests for both Scala and C++ components
- Integration tests for Eagle-Shardok communication
- Map validation tests ensure game content integrity
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
## Game Content
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
**Data Files:** TSV format for battalions, heroes, and other game data
## Deployment
- Bazel handles multi-language builds and dependencies
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
+16
View File
@@ -0,0 +1,16 @@
actions:
- name: "Test all targets"
container_image: "ubuntu-22.04"
triggers:
push:
branches:
- "main" # <-- replace "main" with your main branch name
pull_request:
branches:
- "*"
steps:
- run: "bazel build //src/main/go/net/eagle0/client_download"
storage:
ttl_seconds: 86400 # 1 day
disk:
root_directory: "/tmp/buildbuddy"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/zsh
echo "***"
echo "*** Moving files to workspace"
mv /Users/dancrosby/NewInvokeAI/outputs/images/*.png /Users/dancrosby/Downloads/new_heroes/
echo "***"
echo "*** Renaming files"
bazel run src/main/go/net/eagle0/util/hero_generation/pngorganizer /Users/dancrosby/Downloads/new_heroes/
echo "***"
echo "*** Moving files to generated"
mv /Users/dancrosby/Downloads/new_heroes/generated/*.png /Users/dancrosby/Documents/headshots/generated
echo "***"
echo "*** Syncing to server"
./scripts/sync_headshots.sh
echo "***"
echo "*** Checking which new heroes have images and adjusting TSVs"
bazel run //src/main/go/net/eagle0/util/hero_generation/imagechecker -- /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/waiting_headshots_heroes.herodata /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/generated_heroes.tsv /Users/dancrosby/Documents/headshots/
echo "***"
echo "*** Deduplicate names"
bazel run //src/main/go/net/eagle0/util/hero_generation/namededuplicator /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/generated_heroes.tsv
rm src/main/resources/net/eagle0/eagle/generated_heroes.tsv.backup
echo "***"
echo "*** Generating new SD prompts"
bazel run src/main/go/net/eagle0/util/hero_generation/heroformatter ${PWD}/src/main/resources/net/eagle0/eagle/waiting_headshots_heroes.herodata ~/samplelines.txt
@@ -60,6 +60,7 @@
<Analyzer Include="/Applications/Unity/Hub/Editor/6000.0.32f1/Unity.app/Contents/Tools/Unity.SourceGenerators/Unity.UIToolkit.SourceGenerator.dll" />
</ItemGroup>
<ItemGroup>
<Compile Include="Assets/Eagle/GeneratedTextListener.cs" />
<Compile Include="Assets/Eagle/Notifications/NewRoundNotificationGenerator.cs" />
<Compile Include="Assets/common/CommonExtensions.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Switch/SwitchManagerEditor.cs" />
@@ -76,6 +77,7 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/SliderManager.cs" />
<Compile Include="Assets/common/GUIUtils/TableRowHoverDetector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/DynamicHeroTextUpdater.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/util/HeroDropdownController.cs" />
<Compile Include="Assets/Eagle/Notifications/PrisonerReturnedNotificationGenerator.cs" />
@@ -122,8 +124,8 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerIcon.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/HandleRiotDoNothingCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExecutedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/common/AWSResourceFetcher.cs" />
<Compile Include="Assets/Eagle/CommandButtonPanelController.cs" />
<Compile Include="Assets/Eagle/Table Rows/DynamicFactionTextUpdater.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Demo/DemoListShadow.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeMinSlider.cs" />
<Compile Include="Assets/HoveringTooltipTextProvider.cs" />
@@ -226,9 +228,11 @@
<Compile Include="Assets/Eagle/PanelPositions.cs" />
<Compile Include="Assets/Eagle/Notifications/NotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Switch/SwitchManager.cs" />
<Compile Include="Assets/Eagle/Notifications/DynamicTextNotification.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerToggle.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerAnimatedIcon.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Input Field/CustomInputField.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuContent.cs" />
@@ -48,9 +48,8 @@ public class AvailableGameItem : MonoBehaviour {
joinButton.interactable = true;
buttonText.text = "JOIN";
List<HeroView> heroOptions = new List<HeroView> {
new() { Name = "Random", Id = 0, Profession = Profession.NoProfession }
};
List<HeroView> heroOptions =
new List<HeroView> { new() { Id = 0, Profession = Profession.NoProfession } };
for (int i = 0; i < leaders.Count; i++) {
var leader = leaders[i];
heroOptions.Add(new HeroView {
@@ -60,6 +59,7 @@ public class AvailableGameItem : MonoBehaviour {
});
}
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
}
}
@@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -19,9 +21,13 @@ public class AuthInterceptor : Interceptor {
private readonly string headerName = "Authorization";
private string headerValue;
public AuthInterceptor(String name, String password) {
public static string HeaderValue(string name, string password) {
String toEncode = name + ":" + password;
headerValue = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(toEncode));
return Convert.ToBase64String(Encoding.UTF8.GetBytes(toEncode));
}
public AuthInterceptor(String name, String password) {
headerValue = "Basic " + HeaderValue(name, password);
}
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
TRequest request,
@@ -98,6 +104,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
int fetchedMaxPlayerCount;
private EagleConnection eagleConnection;
private PersistentClientConnection _persistentClientConnection;
private HttpClient _httpClient;
private Thread lobbyUpdatesThread;
private bool listen = false;
private readonly Object pendingReplyLock = new Object();
@@ -171,7 +178,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
fetchedMaxPlayerCount = lobbyResponse.NewGameOptions.MaxSupportedPlayers;
MainQueue.Q.Enqueue(() => {
AWSResourceFetcher.headshotFetcher.Prefetch(
ResourceFetcher.headshotFetcher.Prefetch(
fetchedNewGameLeaders.Select(a => a.ImagePath));
connectionPanel.gameObject.SetActive(false);
@@ -256,7 +263,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
eagleConnection.credentials,
EagleConnection.EagleCancellationToken);
_persistentClientConnection.Connect();
AWSResourceFetcher.SetUpConnection(_persistentClientConnection);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
ResourceFetcher.SetUpConnection(_httpClient);
}
private void _internalCustomBattle() {
@@ -60,9 +60,8 @@ public class CreateGameItem : MonoBehaviour {
buttonText.text = "CREATE";
List<HeroView> heroOptions = new List<HeroView> {
new HeroView { Name = "Random", Id = 0, Profession = Profession.NoProfession }
};
List<HeroView> heroOptions =
new List<HeroView> { new() { Id = 0, Profession = Profession.NoProfession } };
for (int i = 0; i < leaders.Count; i++) {
var leader = leaders[i];
heroOptions.Add(new HeroView {
@@ -73,6 +72,7 @@ public class CreateGameItem : MonoBehaviour {
});
}
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
heroDropdownController.HeadshotImage.gameObject.SetActive(false);
@@ -135,7 +135,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
var unit = yourUnitsController.ComponentAt<CustomUnitRowController>(i).Unit;
unit.Hero.EagleHeroId = nextEagleHeroId;
yourSetupInfo.Units.Add(unit);
_heroNames[nextEagleHeroId] = unit.Hero.Name;
_heroNames[nextEagleHeroId] = unit.Hero.NameTextId;
_heroImages[nextEagleHeroId] = "no_profession/male/000107.1333771077.png";
nextEagleHeroId++;
}
@@ -157,7 +157,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
var unit = aiUnitsController.ComponentAt<CustomUnitRowController>(i).Unit;
unit.Hero.EagleHeroId = nextEagleHeroId;
aiSetupInfo.Units.Add(unit);
_heroNames[nextEagleHeroId] = unit.Hero.Name;
_heroNames[nextEagleHeroId] = unit.Hero.NameTextId;
nextEagleHeroId++;
}
@@ -26,58 +26,48 @@ namespace eagle {
private readonly HashSet<IClientTextListener> _listeners = new();
public void Clear() {
lock (this) { _streamingTexts.Clear(); }
}
public void Clear() { _streamingTexts.Clear(); }
public Dictionary<String, TextEntry> All() {
lock (this) { return new Dictionary<string, TextEntry>(_streamingTexts); }
return new Dictionary<string, TextEntry>(_streamingTexts);
}
public String
HandleNewStreamingText(String llmId, String newText, Int32 knownByteCount, bool completed) {
lock (this) {
var currentText = "";
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
var currentText = "";
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
_listeners.Where(x => x.TextId() == llmId)
.ToList()
.ForEach(x => x.OnTextUpdate(updatedText, completed));
_listeners.Where(x => x.TextId() == llmId)
.ToList()
.ForEach(x => x.OnTextUpdate(updatedText, completed));
return updatedText;
}
return updatedText;
}
public TextEntry GetTextEntry(string streamId) {
lock (this) {
if (String.IsNullOrEmpty(streamId)) return new TextEntry("", false);
if (String.IsNullOrEmpty(streamId)) return new TextEntry("", false);
if (ClientPregeneratedText.Provider.TryGetText(streamId, out var text)) {
return new TextEntry(text, true);
}
return _streamingTexts.GetValueOrDefault(streamId, null);
if (ClientPregeneratedText.Provider.TryGetText(streamId, out var text)) {
return new TextEntry(text, true);
}
return _streamingTexts.GetValueOrDefault(streamId, null);
}
public void AddListener(IClientTextListener listener) {
lock (this) {
if (listener.TextId() is {} textId && !String.IsNullOrEmpty(textId)) {
_listeners.Add(listener);
var entry = GetTextEntry(textId);
if (entry != null) { listener.OnTextUpdate(entry.Text, entry.Completed); }
}
_listeners.Add(listener);
if (listener.TextId() is {} textId && !String.IsNullOrEmpty(textId)) {
var entry = GetTextEntry(textId);
if (entry != null) { listener.OnTextUpdate(entry.Text, entry.Completed); }
}
}
public void RemoveListener(IClientTextListener listener) {
lock (this) { _listeners.Remove(listener); }
}
public void RemoveListener(IClientTextListener listener) { _listeners.Remove(listener); }
}
}
@@ -13,6 +13,7 @@ namespace eagle {
using FactionId = Int32;
public class ApprehendOutlawCommandSelector : CommandSelector {
private List<GeneratedTextListener> outlawListeners = new();
private HeroId SelectedHeroId => actingHeroDropdownController.SelectedHeroId;
private BattalionId? SelectedBattalionId =>
actingBattalionDropdown.value == 0
@@ -48,13 +49,22 @@ namespace eagle {
.OrderBy(batt => batt.Type)
.ToList();
private string outlawDropdownString(ResidentOutlaw outlaw) {
string lastFactionString = "";
if (outlaw.LastFactionId is FactionId fid) {
lastFactionString = $" ({_model.FactionName(fid)})";
private void ClearOutlawListeners() {
foreach (var listener in outlawListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
return $"{outlaw.Hero.Name}{lastFactionString}";
outlawListeners.Clear();
}
private void SetOutlawText(int index, string text) {
if (index >= targetOutlawDropdown.options.Count) return;
targetOutlawDropdown.options[index].text = text;
targetOutlawDropdown.RefreshShownValue();
}
private string GetFactionSuffix(ResidentOutlaw outlaw) {
if (outlaw.LastFactionId is FactionId fid) { return $" ({_model.FactionName(fid)})"; }
return "";
}
private TMP_Dropdown.OptionData BattalionOption(BattalionView bv) {
@@ -84,11 +94,34 @@ namespace eagle {
actingBattalionDropdown.value = Math.Min(1, AvailableBattalions.Count);
targetOutlawDropdown.ClearOptions();
var outlawNames =
ApprehendOutlawCommand.Outlaws.Select(opt => outlawDropdownString(opt))
.ToList();
targetOutlawDropdown.AddOptions(outlawNames);
ClearOutlawListeners();
// Create empty options first, text will be populated by listeners
var emptyOptions = ApprehendOutlawCommand.Outlaws
.Select(
_ => new TMP_Dropdown.OptionData { text = "" })
.ToList();
targetOutlawDropdown.AddOptions(emptyOptions);
// Set up listeners for each outlaw's name text
for (int i = 0; i < ApprehendOutlawCommand.Outlaws.Count; i++) {
var outlaw = ApprehendOutlawCommand.Outlaws[i];
var suffix = GetFactionSuffix(outlaw);
if (!string.IsNullOrEmpty(outlaw.Hero.NameTextId)) {
var listener = new GeneratedTextListener(
outlaw.Hero.NameTextId,
"",
suffix,
i,
SetOutlawText);
outlawListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
targetOutlawDropdown.value = 0;
}
@@ -100,5 +133,7 @@ namespace eagle {
ActingHeroId = SelectedHeroId
}
};
void OnDestroy() { ClearOutlawListeners(); }
}
}
@@ -13,6 +13,8 @@ namespace eagle {
using HeroId = Int32;
public class DefendCommandSelector : CommandSelector {
private GeneratedTextListener warningTextListener;
private string cachedWarningText = "";
private int RallyPointProvinceIndex => rallyPointDropdown.value;
private List<CombatUnit> SelectedActingUnits => unitSelector.SelectedActingUnits;
@@ -72,9 +74,11 @@ namespace eagle {
if (BadlyConstructedUnits) {
var count = unitSelector.HeroIdsLeadingInappropriateUnits.Count;
if (count == 1) {
txt += $"{_model.Heroes[unitSelector.HeroIdsLeadingInappropriateUnits[0]].Name} is leading a battalion type that would restrict their abilities.\n\n";
txt += !string.IsNullOrEmpty(cachedWarningText)
? cachedWarningText
: "A hero is leading a battalion type that would restrict their abilities.\n\n";
} else {
txt += $"Some of your heroes are leading battalion types that would restrict their abilities.\n\n";
txt += "Some of your heroes are leading battalion types that would restrict their abilities.\n\n";
}
}
@@ -147,6 +151,36 @@ namespace eagle {
troopCountText.text = $"{DefendCommand.HostileTroopCount}";
SetUpUnitSelector();
SetupWarningTextListener();
}
private void ClearWarningTextListener() {
if (warningTextListener != null) {
ClientTextProvider.Provider.RemoveListener(warningTextListener);
warningTextListener = null;
}
}
private void SetupWarningTextListener() {
ClearWarningTextListener();
// Only set up if there's exactly one hero with inappropriate units
if (unitSelector.HeroIdsLeadingInappropriateUnits.Count == 1) {
var heroId = unitSelector.HeroIdsLeadingInappropriateUnits[0];
var hero = _model.Heroes[heroId];
if (!string.IsNullOrEmpty(hero.NameTextId)) {
warningTextListener = new GeneratedTextListener(
hero.NameTextId,
"",
" is leading a battalion type that would restrict their abilities.\n\n",
0,
(index, text) => { cachedWarningText = text; });
ClientTextProvider.Provider.AddListener(warningTextListener);
}
}
}
public override SelectedCommand Command {
@@ -159,5 +193,7 @@ namespace eagle {
return cmd;
}
}
void OnDestroy() { ClearWarningTextListener(); }
}
}
@@ -11,6 +11,8 @@ namespace eagle {
using HeroId = Int32;
public class HeroGiftCommandSelector : CommandSelector {
private GeneratedTextListener warningTextListener;
private string cachedWarningText = "";
public TMP_Dropdown provinceDropdown;
public HeroDropdownController heroDropdownController;
public Slider goldSlider;
@@ -82,8 +84,7 @@ namespace eagle {
}
}
public override bool WarnOnCommitButton => SelectedHero.Loyalty.Stat >= 100;
public override string CommitWarningText =>
$"{SelectedHero.Name} already has maximum loyalty!";
public override string CommitWarningText => cachedWarningText;
void Awake() {
heroDropdownController.ConditionAccessor = hero => hero.Loyalty;
@@ -108,6 +109,28 @@ namespace eagle {
goldSlider.maxValue = AvailableGold;
}
private void ClearWarningTextListener() {
if (warningTextListener != null) {
ClientTextProvider.Provider.RemoveListener(warningTextListener);
warningTextListener = null;
}
}
private void SetupWarningTextListener() {
ClearWarningTextListener();
if (!string.IsNullOrEmpty(SelectedHero.NameTextId)) {
warningTextListener = new GeneratedTextListener(
SelectedHero.NameTextId,
"",
" already has maximum loyalty!",
0,
(index, text) => { cachedWarningText = text; });
ClientTextProvider.Provider.AddListener(warningTextListener);
}
}
public void SetUpHeroDropdown() {
var heroes = AvailableHeroIds.Select(hid => _model.Heroes[hid]).ToList();
@@ -120,11 +143,17 @@ namespace eagle {
}
}
heroDropdownController.SelectedHeroId = minLoyaltyHero.Id;
// Set up warning text listener for the selected hero
SetupWarningTextListener();
}
public void HeroSelected() {
goldSlider.maxValue = AvailableGold;
goldSlider.value = Math.Min(goldSlider.value, AvailableGold);
// Update warning text listener when hero selection changes
SetupWarningTextListener();
}
public void GoldPlusClicked() {
@@ -151,5 +180,7 @@ namespace eagle {
return cmd;
}
}
void OnDestroy() { ClearWarningTextListener(); }
}
}
@@ -13,6 +13,7 @@ namespace eagle {
using ProvinceId = Int32;
public class IssueOrdersCommandSelector : CommandSelector {
private List<GeneratedTextListener> rulingHeroListeners = new();
IList<ProvinceOrderType> availableOrderTypes;
Dictionary<ProvinceId, ProvinceOrderType> currentOrders;
List<ProvinceOrders> newOrders;
@@ -63,7 +64,15 @@ namespace eagle {
(SelectedFocusProvince != startingFocusProvince);
public override string DisabledCommitButtonReason => "No province orders have changed";
private void ClearRulingHeroListeners() {
foreach (var listener in rulingHeroListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
rulingHeroListeners.Clear();
}
override protected void SetUpUI() {
ClearRulingHeroListeners();
availableOrderTypes = IssueOrdersAvailableCommand.AvailableOrders;
currentOrders = IssueOrdersAvailableCommand.CurrentOrders.ToDictionary(
x => x.ProvinceId,
@@ -101,7 +110,36 @@ namespace eagle {
var textComponents = newRow.GetComponentsInChildren<TextMeshProUGUI>();
textComponents[0].text = province.Name;
textComponents[1].text = GUIUtils.ConditionString(province.FullInfo.Support);
textComponents[2].text = _model.Heroes[province.FullInfo.RulingHeroId.Value].Name;
// Set up ruling hero name with listener
var rulingHeroTextComponent = textComponents[2];
rulingHeroTextComponent.text = ""; // Start empty, will be populated by listener
if (province.FullInfo.RulingHeroId.HasValue) {
var rulingHero = _model.Heroes[province.FullInfo.RulingHeroId.Value];
if (!string.IsNullOrEmpty(rulingHero.NameTextId)) {
var listener = new GeneratedTextListener(
rulingHero.NameTextId,
"",
"",
i,
(index, text) => {
if (index < scrollContent.transform.childCount) {
var rowTransform = scrollContent.transform.GetChild(index);
var rowTextComponents =
rowTransform
.GetComponentsInChildren<TextMeshProUGUI>();
if (rowTextComponents.Length > 2) {
rowTextComponents[2].text = text;
}
}
});
rulingHeroListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
textComponents[3].text = province.FullInfo.RulingFactionHeroIds.Count.ToString();
var ordersDropdown = newRow.GetComponentInChildren<TMP_Dropdown>();
@@ -141,5 +179,7 @@ namespace eagle {
public void ProvinceOrdersValueChanged(int index, Int32 newValue) {
newOrders[index].Orders = availableOrderTypes[newValue];
}
void OnDestroy() { ClearRulingHeroListeners(); }
}
}
@@ -22,6 +22,8 @@ namespace eagle {
}
public class MarchCommandSelector : CommandSelector {
private GeneratedTextListener warningTextListener;
private string cachedWarningText = "";
private const string NoProvinceSelectedText = "Select...";
private bool HasNoneOption => toDropdown.options.Last().text == NoProvinceSelectedText;
@@ -138,7 +140,9 @@ namespace eagle {
if (BadlyConstructedUnits) {
var count = unitSelector.HeroIdsLeadingInappropriateUnits.Count;
if (count == 1) {
txt += $"{_model.Heroes[unitSelector.HeroIdsLeadingInappropriateUnits[0]].Name} is leading a battalion type that would restrict their abilities\n\n";
txt += !string.IsNullOrEmpty(cachedWarningText)
? cachedWarningText
: "A hero is leading a battalion type that would restrict their abilities\n\n";
} else {
txt += "Some of your heroes are leading battalion types that would restrict their abilities\n\n";
}
@@ -320,6 +324,36 @@ namespace eagle {
SetFoodLabels();
SetGoldLabels();
SetupWarningTextListener();
}
private void ClearWarningTextListener() {
if (warningTextListener != null) {
ClientTextProvider.Provider.RemoveListener(warningTextListener);
warningTextListener = null;
}
}
private void SetupWarningTextListener() {
ClearWarningTextListener();
// Only set up if there's exactly one hero with inappropriate units
if (unitSelector.HeroIdsLeadingInappropriateUnits.Count == 1) {
var heroId = unitSelector.HeroIdsLeadingInappropriateUnits[0];
var hero = _model.Heroes[heroId];
if (!string.IsNullOrEmpty(hero.NameTextId)) {
warningTextListener = new GeneratedTextListener(
hero.NameTextId,
"",
" is leading a battalion type that would restrict their abilities\n\n",
0,
(index, text) => { cachedWarningText = text; });
ClientTextProvider.Provider.AddListener(warningTextListener);
}
}
}
public void GoldMinusClicked() {
@@ -381,5 +415,7 @@ namespace eagle {
return null;
}
}
void OnDestroy() { ClearWarningTextListener(); }
}
}
@@ -8,6 +8,8 @@ namespace eagle {
using ProvinceId = Int32;
public class PleaseRecruitMeCommandSelector : PopupPanelController {
private List<GeneratedTextListener> heroNameListeners = new();
private List<string> cachedHeroNames = new();
struct HeroInfo {
public ProvinceId provinceId;
public ExpandedUnaffiliatedHero hero;
@@ -26,9 +28,11 @@ namespace eagle {
set {
_availableCommand = value;
if (value == null) {
ClearHeroNameListeners();
_offers = new List<HeroInfo>();
PopupInfos = new List<PopupInfo>();
} else {
ClearHeroNameListeners();
_offers = _availableCommand.PleaseRecruitMeCommand.AvailableProvinces
.SelectMany(
p => p.AvailableHeroes.Select(
@@ -38,16 +42,18 @@ namespace eagle {
}))
.ToList();
PopupInfos =
_offers.Select(heroInfo => new PopupInfo {
titleText = "Please Recruit Me!",
mainText =
$"{heroInfo.hero.Hero.Name} wishes to join us in {Model.Provinces[heroInfo.provinceId].Name}.\n\n",
mainTextLlmId = heroInfo.hero.PleaseRecruitMeTextId,
affectedProvinceIds =
new List<ProvinceId>(heroInfo.provinceId)
})
.ToList();
// Initialize cached hero names and set up listeners
cachedHeroNames = new List<string>(new string[_offers.Count]);
SetupHeroNameListeners();
PopupInfos = _offers.Select((heroInfo, index) => new PopupInfo {
titleText = "Please Recruit Me!",
mainText = GetHeroMainText(index, heroInfo.provinceId),
mainTextLlmId = heroInfo.hero.PleaseRecruitMeTextId,
affectedProvinceIds =
new List<ProvinceId>(heroInfo.provinceId)
})
.ToList();
SetUpPanel();
}
@@ -56,6 +62,53 @@ namespace eagle {
private List<HeroInfo> _offers = new List<HeroInfo>();
private void ClearHeroNameListeners() {
foreach (var listener in heroNameListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
heroNameListeners.Clear();
}
private void SetupHeroNameListeners() {
for (int i = 0; i < _offers.Count; i++) {
var heroInfo = _offers[i];
if (!string.IsNullOrEmpty(heroInfo.hero.Hero.NameTextId)) {
var listener = new GeneratedTextListener(
heroInfo.hero.Hero.NameTextId,
"",
"",
i,
(index, text) => {
if (index < cachedHeroNames.Count) {
cachedHeroNames[index] = text;
UpdatePopupInfoMainText(index);
}
});
heroNameListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
}
private string GetHeroMainText(int index, ProvinceId provinceId) {
var heroName = !string.IsNullOrEmpty(cachedHeroNames[index]) ? cachedHeroNames[index]
: "A hero";
return $"{heroName} wishes to join us in {Model.Provinces[provinceId].Name}.\n\n";
}
private void UpdatePopupInfoMainText(int index) {
if (index < PopupInfos.Count && index < _offers.Count) {
var heroInfo = _offers[index];
PopupInfos[index] = new PopupInfo {
titleText = "Please Recruit Me!",
mainText = GetHeroMainText(index, heroInfo.provinceId),
mainTextLlmId = heroInfo.hero.PleaseRecruitMeTextId,
affectedProvinceIds = new List<ProvinceId>(heroInfo.provinceId)
};
}
}
private SelectedCommand SelectedCommand(bool accept) {
return new SelectedCommand {
PleaseRecruitMeCommand =
@@ -86,5 +139,7 @@ namespace eagle {
heroDetailsController.SetHero(_offers[DisplayedIndex].hero.Hero, Model);
}
}
void OnDestroy() { ClearHeroNameListeners(); }
}
}
@@ -14,6 +14,8 @@ namespace eagle {
using ProvinceId = Int32;
public class RansomCommandSelector : CommandSelector {
private List<GeneratedTextListener> prisonerDropdownListeners = new();
private List<GeneratedTextListener> tableListeners = new();
private FactionId SelectedFactionId =>
AvailableTargetFactionIds[targetFactionDropdown.value];
private PrisonerToBeRansomed SelectedPrisonerToBeRansomed =>
@@ -188,9 +190,34 @@ namespace eagle {
options.Select(opt => opt.RansomOffer.PrisonerToBeRansomed).ToList();
prisonerToBeRansomedDropdown.ClearOptions();
prisonerToBeRansomedDropdown.AddOptions(
AvailablePrisonersToBeRansomed.Select(p => _model.Heroes[p.PrisonerHeroId].Name)
.ToList());
ClearPrisonerDropdownListeners();
// Create empty options first, text will be populated by listeners
var emptyOptions = AvailablePrisonersToBeRansomed
.Select(
_ => new TMP_Dropdown.OptionData { text = "" })
.ToList();
prisonerToBeRansomedDropdown.AddOptions(emptyOptions);
// Set up listeners for each prisoner's name text
for (int i = 0; i < AvailablePrisonersToBeRansomed.Count; i++) {
var prisoner = AvailablePrisonersToBeRansomed[i];
var hero = _model.Heroes[prisoner.PrisonerHeroId];
if (!string.IsNullOrEmpty(hero.NameTextId)) {
var listener = new GeneratedTextListener(
hero.NameTextId,
"",
"",
i,
SetPrisonerDropdownText);
prisonerDropdownListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
prisonerToBeRansomedDropdown.value = 0;
AvailableHostages = RansomOffer.HostagesOffered.Select(p => p.HeroId).ToList();
@@ -199,21 +226,91 @@ namespace eagle {
UpdateTables();
}
private void ClearPrisonerDropdownListeners() {
foreach (var listener in prisonerDropdownListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
prisonerDropdownListeners.Clear();
}
private void ClearTableListeners() {
foreach (var listener in tableListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
tableListeners.Clear();
}
private void SetPrisonerDropdownText(int index, string text) {
if (index >= prisonerToBeRansomedDropdown.options.Count) return;
prisonerToBeRansomedDropdown.options[index].text = text;
prisonerToBeRansomedDropdown.RefreshShownValue();
}
private void UpdateTables() {
ClearTableListeners();
prisonersTable.RowCount = 0;
int prisonerIndex = 0;
foreach (HeroId prisonerHid in PrisonerHeroIds) {
var row = prisonersTable.AddRowWithComponent<AvailableHeroTableRow>();
row.HeroName = _model.Heroes[prisonerHid].Name;
var hero = _model.Heroes[prisonerHid];
// Start with empty name, will be populated by listener
row.HeroName = "";
row.Selectable = true;
row.Selected = false;
// Set up listener for this table row
if (!string.IsNullOrEmpty(hero.NameTextId)) {
var listener = new GeneratedTextListener(
hero.NameTextId,
"",
"",
prisonerIndex,
(index, text) => {
if (index < prisonersTable.RowCount) {
var tableRow = prisonersTable.GetComponentInChildren<
AvailableHeroTableRow>();
if (tableRow != null) tableRow.HeroName = text;
}
});
tableListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
prisonerIndex++;
}
hostagesTable.RowCount = 0;
int hostageIndex = 0;
foreach (HeroId hostageHid in HostageHeroIds) {
var row = hostagesTable.AddRowWithComponent<AvailableHeroTableRow>();
row.HeroName = _model.Heroes[hostageHid].Name;
var hero = _model.Heroes[hostageHid];
// Start with empty name, will be populated by listener
row.HeroName = "";
row.Selectable = true;
row.Selected = false;
// Set up listener for this table row
if (!string.IsNullOrEmpty(hero.NameTextId)) {
var listener = new GeneratedTextListener(
hero.NameTextId,
"",
"",
hostageIndex,
(index, text) => {
if (index < hostagesTable.RowCount) {
var tableRow = hostagesTable.GetComponentInChildren<
AvailableHeroTableRow>();
if (tableRow != null) tableRow.HeroName = text;
}
});
tableListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
hostageIndex++;
}
}
@@ -226,5 +323,10 @@ namespace eagle {
PrisonerHeroIds.RemoveAt(index);
UpdateTables();
}
void OnDestroy() {
ClearPrisonerDropdownListeners();
ClearTableListeners();
}
}
}
@@ -5,13 +5,40 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveAllianceCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveAllianceOfferCommand?.Offers.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
var messengerName = Model.Heroes[MessengerHeroId].Name;
return $"{Model.FactionName(OriginatingFactionId)}'s representative {messengerName}:\n";
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"{Model.FactionName(OriginatingFactionId)}'s representative:\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
$"{Model.FactionName(OriginatingFactionId)}'s representative ",
":\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
}
protected override string TitleText => "Alliance Proposed";
@@ -22,5 +49,7 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -5,13 +5,40 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveBreakAllianceCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveBreakAllianceCommand?.Offers.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
var messengerName = Model.Heroes[MessengerHeroId].Name;
return $"{Model.FactionName(OriginatingFactionId)}'s representative {messengerName}:\n";
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"{Model.FactionName(OriginatingFactionId)}'s representative:\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
$"{Model.FactionName(OriginatingFactionId)}'s representative ",
":\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
}
protected override string TitleText => "Alliance Broken";
@@ -22,5 +49,7 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -6,12 +6,41 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveInvitationCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveInvitationCommand?.Invitations.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) =>
$"{Model.Heroes[MessengerHeroId].Name} brings an invitation to join {Model.FactionName(OriginatingFactionId)}.\n";
protected override string PrefixedText(DiplomacyOffer offer) {
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"A messenger brings an invitation to join {Model.FactionName(OriginatingFactionId)}.\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
"",
$" brings an invitation to join {Model.FactionName(OriginatingFactionId)}.\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
}
protected override string TitleText => "Invited";
public override SelectedCommand selectedCommand(DiplomacyOfferStatus resolution) =>
@@ -22,5 +51,7 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -65,7 +65,7 @@ namespace eagle {
var demandingFaction = _model.MaybeDestroyedFaction(SelectedDemand.DemandingFactionId);
factionDescription.text =
$"{DisplayNames.FactionString(demandingFaction, _model)} demands tribute!";
$"{DisplayNames.ResolvedFactionString(demandingFaction, _model)} demands tribute!";
goldAmountLabel.text = SelectedDemand.TributeDemanded.Gold.ToString();
foodAmountLabel.text = SelectedDemand.TributeDemanded.Food.ToString();
@@ -5,13 +5,40 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveTruceCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveTruceOfferCommand?.Offers.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
var messengerName = Model.Heroes[MessengerHeroId].Name;
return $"{Model.FactionName(OriginatingFactionId)}'s representative {messengerName}:\n";
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"{Model.FactionName(OriginatingFactionId)}'s representative:\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
$"{Model.FactionName(OriginatingFactionId)}'s representative ",
":\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
}
protected override string TitleText => "Truce Proposed";
@@ -23,5 +50,7 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -39,12 +39,7 @@ namespace eagle {
? $" ({GUIUtils.ConditionString(ConditionAccessor(hero), ConditionName)})"
: "",
i,
(index, text) => {
if (index < Dropdown.options.Count) {
options[index].text = text;
AssignOptions();
}
});
SetGeneratedText);
listeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
@@ -54,6 +49,14 @@ namespace eagle {
}
}
public string FallbackText = "";
private void SetGeneratedText(int index, string text) {
if (index >= Dropdown.options.Count) return;
options[index].text = text;
AssignOptions();
}
private List<TMP_Dropdown.OptionData> options = new();
public void AssignOptions() {
Dropdown.ClearOptions();
@@ -115,7 +118,7 @@ namespace eagle {
private void SetHeadshot() {
var imagePath = AvailableHeroes[Dropdown.value].ImagePath;
if (!string.IsNullOrEmpty(imagePath)) {
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, imagePath);
ResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, imagePath);
}
}
@@ -132,7 +135,7 @@ namespace eagle {
}
private TMP_Dropdown.OptionData HeroOption(HeroView hv) {
var texture = Textures.Profession(hv.Profession);
var texture = !Textures ? null : Textures.Profession(hv.Profession);
var op = new TMP_Dropdown.OptionData {
image = !texture ? null
@@ -140,7 +143,7 @@ namespace eagle {
texture,
new Rect(0, 0, texture.width, texture.height),
new Vector2(0.5f, 0.5f)),
text = ""
text = String.IsNullOrEmpty(hv.NameTextId) ? FallbackText : ""
};
return op;
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
@@ -79,128 +78,6 @@ namespace eagle {
}
}
public static string ShortQuestString(Quest quest, IGameModel model) {
if (quest == null) { return $"There is nothing you can do"; }
switch (quest.Details.SealedValueCase) {
case SealedValueOneofCase.AllianceQuest:
return $"Secure an alliance with another faction";
case SealedValueOneofCase.DefeatFactionQuest:
return $"Defeat {model.FactionName(quest.Details.DefeatFactionQuest.TargetFactionId)}";
case SealedValueOneofCase.AlmsToProvinceQuest: {
var province = model.Provinces[quest.Details.AlmsToProvinceQuest.ProvinceId];
var desiredFood = quest.Details.AlmsToProvinceQuest.TotalFood;
var remainingFood = Math.Max(0, desiredFood - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingFood} remaining)" : "";
return $"Give {desiredFood} food in alms in {province.Name}{remainingText}";
}
case SealedValueOneofCase.AlmsAcrossRealmQuest: {
var desiredFood = quest.Details.AlmsAcrossRealmQuest.TotalFood;
var remainingFood = Math.Max(0, desiredFood - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingFood} remaining)" : "";
return $"Give {desiredFood} food in alms across the realm{remainingText}";
}
case SealedValueOneofCase.GiveToHeroesInProvinceQuest: {
var province =
model.Provinces[quest.Details.GiveToHeroesInProvinceQuest.ProvinceId];
var desiredGold = quest.Details.GiveToHeroesInProvinceQuest.TotalGold;
var remainingGold = Math.Max(0, desiredGold - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingGold} remaining)" : "";
return $"Give {desiredGold} gold to heroes in {province.Name}{remainingText}";
}
case SealedValueOneofCase.GiveToHeroesAcrossRealmQuest: {
var desiredGold = quest.Details.GiveToHeroesAcrossRealmQuest.TotalGold;
var remainingGold = Math.Max(0, desiredGold - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingGold} remaining)" : "";
return $"Give {desiredGold} gold to heroes across the realm{remainingText}";
}
case SealedValueOneofCase.ImproveAgricultureQuest:
return $"Raise Agriculture to {(int)Math.Ceiling(quest.Details.ImproveAgricultureQuest.DesiredValue)}";
case SealedValueOneofCase.ImproveEconomyQuest:
return $"Raise Economy to {(int)Math.Ceiling(quest.Details.ImproveEconomyQuest.DesiredValue)}";
case SealedValueOneofCase.ImproveInfrastructureQuest:
return $"Raise Infrastructure to {(int)Math.Ceiling(quest.Details.ImproveInfrastructureQuest.DesiredValue)}";
case SealedValueOneofCase.SpecificExpansionQuest:
return $"Expand to {model.Provinces[quest.Details.SpecificExpansionQuest.ProvinceId].Name}";
case SealedValueOneofCase.DismissSpecificVassalQuest:
return $"Dismiss {model.Heroes[quest.Details.DismissSpecificVassalQuest.TargetHeroId].Name}";
case SealedValueOneofCase.WealthQuest:
return $"Acquire {quest.Details.WealthQuest.Gold} gold and {quest.Details.WealthQuest.Food} food";
case SealedValueOneofCase.UpgradeBattalionQuest:
return $"Raise a {LongBattalionTypeNames[quest.Details.UpgradeBattalionQuest.BattalionTypeId]} to {(int)Math.Ceiling(quest.Details.UpgradeBattalionQuest.MinimumTraining)} training and {(int)Math.Ceiling(quest.Details.UpgradeBattalionQuest.MinimumArmament)} armament";
case SealedValueOneofCase.GrandArmyQuest:
return $"Have {quest.Details.GrandArmyQuest.TotalTroopCount} troops";
case SealedValueOneofCase.TruceWithFactionQuest:
return $"Sign a truce with {model.FactionName(quest.Details.TruceWithFactionQuest.TargetFactionId)}";
case SealedValueOneofCase.TruceCountQuest: {
var currentTruceString = "";
if (model.PlayerId is int fid) {
var currentTruces = model.ActiveFactions[fid].FactionRelationships.Count(
fr => fr.RelationshipLevel !=
FactionRelationshipView.Types.RelationshipLevel.Hostile);
if (currentTruces == 0) {
currentTruceString = " (currently have none)";
} else {
currentTruceString = " (currently have " + currentTruces + ")";
}
}
return $"Have truces with {quest.Details.TruceCountQuest.TruceCount} factions" +
currentTruceString;
}
case SealedValueOneofCase.ExecutePrisonerQuest: {
var details = quest.Details.ExecutePrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Execute prisoner {prisonerName} in {provinceName}";
}
case SealedValueOneofCase.ExilePrisonerQuest: {
var details = quest.Details.ExilePrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Exile prisoner {prisonerName} from {provinceName}";
}
case SealedValueOneofCase.FreePrisonerQuest: {
var details = quest.Details.FreePrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Release {prisonerName} from prison in {provinceName}";
}
case SealedValueOneofCase.ReturnPrisonerQuest: {
var details = quest.Details.ReturnPrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
var factionName = model.FactionName(details.ToFactionId);
return $"Return prisoner {prisonerName} from {provinceName} to {factionName}";
}
default:
return $"Crosby hasn't gotten to quest of type {quest.Details.SealedValueCase} yet";
}
}
public static string RecruitmentStatusString(string name, RecruitmentInfo recruitmentInfo) {
switch (recruitmentInfo.Status) {
case RecruitmentStatus.LowPrestige:
@@ -260,6 +137,7 @@ namespace eagle {
switch (quest.Details.SealedValueCase) {
case SealedValueOneofCase.DefeatFactionQuest: return "Attack Faction";
case SealedValueOneofCase.DismissSpecificVassalQuest: return "Dismiss Vassal";
case SealedValueOneofCase.AllianceQuest: return "Secure Alliance";
case SealedValueOneofCase.AlmsToProvinceQuest: return "Give Alms in Province";
case SealedValueOneofCase.AlmsAcrossRealmQuest: return "Give Alms Across Realm";
case SealedValueOneofCase.GiveToHeroesInProvinceQuest:
@@ -289,7 +167,7 @@ namespace eagle {
return new DateTime(777, month, 1).ToString("MMMM", CultureInfo.InvariantCulture);
}
public static string MonthString(Date date) {
private static string MonthString(Date date) {
return new DateTime(date.Year, date.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
}
@@ -307,7 +185,7 @@ namespace eagle {
case UnaffiliatedHeroType.UnaffiliatedHeroOutlaw: return "Outlaw";
case UnaffiliatedHeroType.UnaffiliatedHeroUnknown:
default: throw new ArgumentOutOfRangeException("Unknown free hero status");
default: throw new ArgumentException("Unknown free hero status");
}
}
@@ -322,7 +200,7 @@ namespace eagle {
case ControlWeatherType.ControlWeatherEndDrought: return "End Drought";
case ControlWeatherType.ControlWeatherUnknown:
default: throw new ArgumentOutOfRangeException("Unknown control weather type");
default: throw new ArgumentException("Unknown control weather type");
}
}
@@ -336,14 +214,16 @@ namespace eagle {
}
}
public static string
MaybeFactionLeaderDescription(FactionView faction, HeroView hero, IGameModel currentModel) {
public static string MaybeFactionLeaderDescriptionTemplate(
FactionView faction,
HeroView hero,
IGameModel currentModel) {
if (faction.FactionHeadId == hero.Id) {
return $"faction leader {hero.Name}";
return "faction leader {HeroName}";
} else if (faction.Leaders.Contains(hero.Id)) {
return $"{FactionString(faction, currentModel)}'s sworn {SiblingDescription(hero.PronounGender)} {hero.Name}";
return $"{FactionStringTemplate(faction, currentModel)}'s sworn {SiblingDescription(hero.PronounGender)} {{HeroName}}";
} else {
return $"{FactionString(faction, currentModel)}'s hero {hero.Name}";
return $"{FactionStringTemplate(faction, currentModel)}'s hero {{HeroName}}";
}
}
@@ -352,7 +232,7 @@ namespace eagle {
else if (str.Length == 1)
return str.ToUpper();
else
return char.ToUpper(str[0]) + str.Substring(1);
return char.ToUpper(str[0]) + str[1..];
}
public static string ListString(IList<String> strings) {
@@ -363,7 +243,7 @@ namespace eagle {
} else {
string str = "";
for (int i = 0; i < strings.Count - 1; i++) { str += strings[i] + ", "; }
str += "and " + strings[strings.Count - 1];
str += "and " + strings[^1];
return str;
}
@@ -382,18 +262,36 @@ namespace eagle {
return ListString(list);
}
private static string
ConditionalFactionString(FactionView faction, IGameModel gameModel, bool useFactionName) {
private static string ConditionalFactionStringTemplate(
FactionView faction,
IGameModel gameModel,
bool useFactionName) {
if (useFactionName) return faction.Name;
return gameModel.Heroes[faction.FactionHeadId].Name;
return "{FactionLeaderName}";
}
public static string FactionString(FactionView faction, IGameModel gameModel) {
return ConditionalFactionString(faction, gameModel, DisplayFactionName);
public static string FactionStringTemplate(FactionView faction, IGameModel gameModel) {
return ConditionalFactionStringTemplate(faction, gameModel, DisplayFactionName);
}
public static string AlternateFactionString(FactionView faction, IGameModel gameModel) {
return ConditionalFactionString(faction, gameModel, !DisplayFactionName);
public static string AlternateFactionStringTemplate(
FactionView faction,
IGameModel gameModel) {
return ConditionalFactionStringTemplate(faction, gameModel, !DisplayFactionName);
}
public static string ResolvedFactionString(FactionView faction, IGameModel gameModel) {
var template = FactionStringTemplate(faction, gameModel);
if (template.Contains("{FactionLeaderName}")) {
var hero = gameModel.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
return template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
return template.Replace("{FactionLeaderName}", "Leader");
}
}
return template;
}
}
}
@@ -77,6 +77,8 @@ namespace eagle {
GameModelUpdater ModelUpdater { get; set; }
IGameModel Model { get; set; }
private DynamicFactionTextUpdater factionTextUpdater = new DynamicFactionTextUpdater();
private IGameModel _newModel = null;
public Canvas shardokCanvas;
@@ -334,7 +336,7 @@ namespace eagle {
}
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
AWSResourceFetcher.headshotFetcher.Prefetch(heroViews.Select(h => h.ImagePath));
ResourceFetcher.headshotFetcher.Prefetch(heroViews.Select(h => h.ImagePath));
}
private void PrefetchHeadshots(IGameModel model) {
@@ -410,13 +412,18 @@ namespace eagle {
void SetHeaderString() {
var date = Model.CurrentDate;
if (date != null) {
string factionLabel = "Observer";
string dateString = DisplayNames.DateString(date);
if (Model.PlayerId.HasValue) {
var faction = Model.MaybeDestroyedFaction(Model.PlayerId.Value);
factionLabel =
$"{DisplayNames.FactionString(faction, Model)} ({faction.Prestige})";
factionTextUpdater.SetFactionTextWithCustomFormat(
roundStatusLabel,
faction,
Model,
$"{dateString} - {{FactionLeaderName}} ({{Prestige}})",
$"{dateString} - Observer");
} else {
roundStatusLabel.text = $"{dateString} - Observer";
}
roundStatusLabel.text = $"{DisplayNames.DateString(date)} - {factionLabel}";
}
}
@@ -646,5 +653,7 @@ namespace eagle {
var textField = gameIdButton.GetComponentInChildren<Text>();
textField.text = textField.text == "" ? ModelUpdater.GameId.ToString("X") : "";
}
private void OnDestroy() { factionTextUpdater?.Dispose(); }
}
}
@@ -9,6 +9,7 @@ using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
using Logger = common.Logger;
@@ -88,7 +89,7 @@ namespace eagle {
notification.DisplayedHeroes);
}
private readonly AWSResourceFetcher _awsResourceFetcher;
private readonly ResourceFetcher resourceFetcher;
public long GameId { get; }
@@ -176,7 +177,17 @@ namespace eagle {
return DestroyedFactions[factionId];
}
public string FactionName(FactionId factionId) {
return DisplayNames.FactionString(MaybeDestroyedFaction(factionId), this);
return DisplayNames.ResolvedFactionString(MaybeDestroyedFaction(factionId), this);
}
public void SetDynamicFactionText(
TMP_Text textComponent,
FactionId factionId,
DynamicFactionTextUpdater textUpdater,
string template = null,
string fallbackText = null) {
var faction = MaybeDestroyedFaction(factionId);
textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
}
public RollFetcher RollFetcher { get; set; }
@@ -235,7 +246,10 @@ namespace eagle {
locationName: _currentModel.Provinces[defenderProvince].Name,
month: _currentModel.CurrentDate.Month,
players: players,
heroNames: _currentModel.Heroes.ToDictionary(kv => kv.Key, kv => kv.Value.Name),
heroNames: _currentModel.Heroes.ToDictionary(
kv => kv.Key,
kv => ClientTextProvider.Provider.GetTextEntry(kv.Value.NameTextId)
.Text),
heroImages: _currentModel.Heroes.ToDictionary(
kv => kv.Key,
kv => kv.Value.ImagePath),
@@ -11,11 +11,14 @@ using FactionId = System.Int32;
using ProvinceId = System.Int32;
namespace eagle {
public class FactionsTableController : MonoBehaviour {
public class FactionsTableController : MonoBehaviour, System.IDisposable {
public GameObject panel;
public GameObject factionPopup;
public TMP_Text factionPopupText;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
private GeneratedTextListener currentListener;
private ProvinceView _currentProvince;
public ProvinceView CurrentProvince {
get => _currentProvince;
@@ -69,14 +72,47 @@ namespace eagle {
private string ColoredFactionString(FactionId fid) {
var color = Color.Lerp(PlayerColors.LightPlayerColor(fid), Color.black, 0.5f);
return GUIUtils.ColoredString(color, Model.FactionName(fid));
var factionName = GetResolvedFactionName(fid);
return GUIUtils.ColoredString(color, factionName);
}
private string GetResolvedFactionName(FactionId fid) {
var faction = Model.MaybeDestroyedFaction(fid);
var template = DisplayNames.FactionStringTemplate(faction, Model);
if (template.Contains("{FactionLeaderName}")) {
var hero = Model.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
return template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
return template.Replace("{FactionLeaderName}", "Leader");
}
}
return template;
}
private string GetResolvedAlternateFactionName(FactionView faction) {
var template = DisplayNames.AlternateFactionStringTemplate(faction, Model);
if (template.Contains("{FactionLeaderName}")) {
var hero = Model.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
return template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
return template.Replace("{FactionLeaderName}", "Leader");
}
}
return template;
}
private void LongHoveredFactionChanged(FactionId? newFid) {
if (newFid is {} fid) {
var faction = Model.MaybeDestroyedFaction(fid);
var text =
$"{ColoredFactionString(fid)} is led by {Model.Heroes[faction.FactionHeadId].Name}.";
var baseText = $"{ColoredFactionString(fid)} is led by ";
string frText = "";
foreach (var fr in faction.FactionRelationships) {
@@ -95,12 +131,39 @@ namespace eagle {
}
}
if (frText != "") { text += "\n" + frText; }
var fullText = baseText + "{FactionLeader}.";
if (frText != "") { fullText += "\n" + frText; }
factionPopupText.text = text;
if (currentListener != null) {
ClientTextProvider.Provider.RemoveListener(currentListener);
}
var hero = Model.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
factionPopupText.text = fullText.Replace("{FactionLeader}", textEntry.Text);
} else {
factionPopupText.text = fullText.Replace("{FactionLeader}", "Leader");
currentListener = new GeneratedTextListener(
hero.NameTextId,
baseText,
".",
0,
(index, text) => {
var updatedText = baseText + text + ".";
if (frText != "") { updatedText += "\n" + frText; }
factionPopupText.text = updatedText;
});
ClientTextProvider.Provider.AddListener(currentListener);
}
factionPopup.SetActive(true);
} else {
if (currentListener != null) {
ClientTextProvider.Provider.RemoveListener(currentListener);
currentListener = null;
}
factionPopup.SetActive(false);
}
}
@@ -160,8 +223,8 @@ namespace eagle {
}
private void SetUpFactionRow(FactionView faction, FactionTableRow row) {
row.Faction = DisplayNames.FactionString(faction, Model);
row.Tooltip = DisplayNames.AlternateFactionString(faction, Model);
row.Faction = GetResolvedFactionName(faction.Id);
row.Tooltip = GetResolvedAlternateFactionName(faction);
row.FactionColor = PlayerColors.LightPlayerColor(faction.Id);
row.ProvinceCount = ProvinceCount(faction);
@@ -268,5 +331,15 @@ namespace eagle {
.ThenBy(fac => ExistingRelationship(fac)?.ResetDate?.Year ?? 0)
.ThenBy(fac => ExistingRelationship(fac)?.ResetDate?.Month ?? 0)
.ToList();
public void Dispose() {
if (currentListener != null) {
ClientTextProvider.Provider.RemoveListener(currentListener);
currentListener = null;
}
textUpdater?.Dispose();
}
private void OnDestroy() { Dispose(); }
}
}
@@ -0,0 +1,30 @@
using System;
namespace eagle {
public class GeneratedTextListener : IClientTextListener {
private readonly string textId;
private readonly string prefix;
private readonly string suffix;
private readonly int index;
private readonly Action<int, string> onTextUpdate;
public void OnTextUpdate(string text, bool completed) {
onTextUpdate(index, $"{prefix}{text}{suffix}");
}
public string TextId() { return textId; }
public GeneratedTextListener(
string textId,
string prefix,
string suffix,
int index,
Action<int, string> callback) {
this.textId = textId;
this.prefix = prefix;
this.suffix = suffix;
this.index = index;
this.onTextUpdate = callback;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ed95fc26230d428fa783e0be9eb353d
@@ -15,10 +15,14 @@ namespace eagle {
_currentCompleted = false;
var scrollRect = GetComponentInParent<ScrollRect>(includeInactive: true);
if (scrollRect != null) { scrollRect.normalizedPosition = new Vector2(0, 1); }
if (scrollRect) { scrollRect.normalizedPosition = new Vector2(0, 1); }
}
OnTextUpdate(ClientTextProvider.Provider.GetTextEntry(TextId));
if (!String.IsNullOrEmpty(_textId)) {
// If the text ID is set, we want to update the view immediately
// to reflect any existing text.
OnTextUpdate(ClientTextProvider.Provider.GetTextEntry(TextId));
}
}
}
@@ -34,7 +34,7 @@ namespace eagle {
private void SetHeroBasics(HeroView value) {
if (value.ImagePath is String imagePath && !string.IsNullOrEmpty(imagePath)) {
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(headshotImage, value.ImagePath);
ResourceFetcher.headshotFetcher.LoadIntoRawImage(headshotImage, value.ImagePath);
}
professionImage.texture =
gameObject.GetComponentInParent<EagleCommonTextures>().Profession(
@@ -32,7 +32,7 @@ namespace eagle {
for (int i = 0; i < movingArmy.UnitDetails.Count; i++) {
var details = movingArmy.UnitDetails[i];
var row = movingArmyPopupTable.ComponentAt<MovingArmyPopupRowController>(i);
row.HeroName.text = Model.Heroes[details.HeroId].Name;
row.SetHeroText(Model.Heroes[details.HeroId].NameTextId, "Hero");
var battalion = details.Battalion;
if (battalion != null) {
@@ -27,27 +27,34 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var imprisoningFactionHead = currentModel.Heroes[imprisoningFactionHeadId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"You have rejected an alliance with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"You have rejected an alliance with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { offeringFactionHead, imprisonedHero});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name}!\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}}!\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, imprisonedHero});
} else {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an alliance from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an alliance from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, offeringFactionHead, imprisonedHero });
@@ -27,27 +27,34 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var imprisoningFactionHead = currentModel.Heroes[imprisoningFactionHeadId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with us, and we and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with us, and we and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { offeringFactionHead, imprisonedHero});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name} in response to us breaking the alliance!\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}} in response to us breaking the alliance!\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, imprisonedHero});
} else {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with {currentModel.FactionName(imprisoningFactionId)}, and {currentModel.FactionName(imprisoningFactionId)} imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with {currentModel.FactionName(imprisoningFactionId)}, and {currentModel.FactionName(imprisoningFactionId)} imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, offeringFactionHead, imprisonedHero });
@@ -24,29 +24,56 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Executed";
string noteText;
string textTemplate;
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = executedHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (executedHero.IsFactionLeader) {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has executed our sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {executedHero.Name} in {province.Name}! They must pay for this atrocity!\n\n";
textTemplate =
$"{DisplayNames.ResolvedFactionString(actingFaction, currentModel)} has executed our sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {{ExecutedHero}} in {province.Name}! They must pay for this atrocity!\n\n";
heroPlaceholders["ExecutedHero"] = (executedHero.NameTextId, "our leader");
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has executed our loyal vassal, {executedHero.Name} in {province.Name}!\n\n";
textTemplate =
$"{DisplayNames.ResolvedFactionString(actingFaction, currentModel)} has executed our loyal vassal, {{ExecutedHero}} in {province.Name}!\n\n";
heroPlaceholders["ExecutedHero"] = (executedHero.NameTextId, "our vassal");
}
} else if (isMyAction) {
noteText =
$"We executed {DisplayNames.MaybeFactionLeaderDescription(victimFaction, executedHero, currentModel)} in {province.Name}.\n\n";
string victimDescription;
if (victimFaction.FactionHeadId == executedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(executedHero.Id)) {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"We executed {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (executedHero.NameTextId, "the hero");
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} executed {DisplayNames.MaybeFactionLeaderDescription(victimFaction, executedHero, currentModel)} in {province.Name}.\n\n";
string victimDescription;
if (victimFaction.FactionHeadId == executedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(executedHero.Id)) {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate =
$"{DisplayNames.ResolvedFactionString(actingFaction, currentModel)} executed {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (executedHero.NameTextId, "the hero");
}
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: noteTitle,
prefixedText: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { firstHero, secondHero });
@@ -25,29 +25,56 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Exiled";
string noteText = "";
string textTemplate;
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = exiledHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (exiledHero.IsFactionLeader) {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has exiled our sworn {DisplayNames.SiblingDescription(exiledHero.PronounGender)} {exiledHero.Name} from {province.Name}!\n\n";
textTemplate =
$"{{ActingFactionLeader}} has exiled our sworn {DisplayNames.SiblingDescription(exiledHero.PronounGender)} {{ExiledHero}} from {province.Name}!\n\n";
heroPlaceholders["ExiledHero"] = (exiledHero.NameTextId, "our leader");
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has exiled our loyal vassal, {exiledHero.Name} from {province.Name}!\n\n";
textTemplate =
$"{{ActingFactionLeader}} has exiled our loyal vassal, {{ExiledHero}} from {province.Name}!\n\n";
heroPlaceholders["ExiledHero"] = (exiledHero.NameTextId, "our vassal");
}
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
} else if (isMyAction) {
noteText = $"We exiled {exiledHero.Name} from {province.Name}.\n\n";
textTemplate = $"We exiled {{ExiledHero}} from {province.Name}.\n\n";
heroPlaceholders["ExiledHero"] = (exiledHero.NameTextId, "the hero");
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has exiled {DisplayNames.MaybeFactionLeaderDescription(victimFaction, exiledHero, currentModel)} from {province.Name}.\n\n";
string victimDescription;
if (victimFaction.FactionHeadId == exiledHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(exiledHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(exiledHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate =
$"{{ActingFactionLeader}} has exiled {victimDescription} from {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (exiledHero.NameTextId, "the hero");
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
if (!victimFaction.FactionHeadId.Equals(exiledHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
}
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: noteTitle,
prefixedText: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { firstHero, secondHero });
@@ -24,30 +24,70 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Imprisoned";
string noteText;
string textTemplate;
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = imprisonedHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (imprisonedHero.IsFactionLeader) {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has imprisoned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {imprisonedHero.Name} in {province.Name}!\n\n";
textTemplate =
$"{{ActingFactionLeader}} has imprisoned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{ImprisonedHero}} in {province.Name}!\n\n";
heroPlaceholders["ImprisonedHero"] = (imprisonedHero.NameTextId, "our leader");
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has imprisoned our loyal vassal, {imprisonedHero.Name} in {province.Name}!\n\n";
textTemplate =
$"{{ActingFactionLeader}} has imprisoned our loyal vassal, {{ImprisonedHero}} in {province.Name}!\n\n";
heroPlaceholders["ImprisonedHero"] = (imprisonedHero.NameTextId, "our vassal");
}
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
} else if (isMyAction) {
noteText =
$"We imprisoned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} in {province.Name}.\n\n";
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate = $"We imprisoned {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has imprisoned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} in {province.Name}.\n\n";
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate =
$"{{ActingFactionLeader}} has imprisoned {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
}
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: noteTitle,
prefixedText: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { firstHero, secondHero });
@@ -21,30 +21,70 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Returned";
string noteText = "";
string textTemplate;
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = imprisonedHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (imprisonedHero.IsFactionLeader) {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has peacefully returned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {imprisonedHero.Name} from {province.Name}!";
textTemplate =
$"{{ActingFactionLeader}} has peacefully returned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{ReturnedHero}} from {province.Name}!";
heroPlaceholders["ReturnedHero"] = (imprisonedHero.NameTextId, "our leader");
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has peacefully returned our loyal vassal, {imprisonedHero.Name} from {province.Name}!";
textTemplate =
$"{{ActingFactionLeader}} has peacefully returned our loyal vassal, {{ReturnedHero}} from {province.Name}!";
heroPlaceholders["ReturnedHero"] = (imprisonedHero.NameTextId, "our vassal");
}
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
} else if (isMyAction) {
noteText =
$"We returned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} from {province.Name}.";
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate = $"We returned {victimDescription} from {province.Name}.";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
} else {
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has peacefully returned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} from {province.Name}.";
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate =
$"{{ActingFactionLeader}} has peacefully returned {victimDescription} from {province.Name}.";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
}
yield return new Notification(
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: details.ProvinceId,
displayedHeroes: new List<HeroView> { firstHero, secondHero });
}
@@ -18,19 +18,34 @@ namespace eagle.Notifications.ARNNotifications {
var provinceIds = new List<ProvinceId> { notif.ProvinceId };
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "DepartedHero", (departedHero.NameTextId, "A hero") }
};
if (notif.FromFactionId == currentModel.PlayerId) {
yield return new Notification(
string textTemplate =
$"{{DepartedHero}} has left your service in {province.Name}.\n\n{notification.LlmMessage}";
yield return new DynamicTextNotification(
title: "Hero Departed",
text: $"{departedHero.Name} has left your service in {province.Name}.\n\n{notification.LlmMessage}",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: province.Id,
singleDisplayedHero: departedHero);
displayedHeroes: new List<HeroView> { departedHero });
} else {
yield return Notification.StreamingNotification(
string textTemplate =
$"{{DepartedHero}} has left {{FactionLeader}}'s service in {province.Name}.\n\n";
heroPlaceholders["FactionLeader"] =
(currentModel.Heroes[faction.FactionHeadId].NameTextId, faction.Name);
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Hero Departed",
prefixedText: $"{departedHero.Name} has left {DisplayNames.FactionString(faction, currentModel)}'s service in {province.Name}.\n\n",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: provinceIds,
displayedHeroes: new List<HeroView> { departedHero, currentModel.Heroes[faction.FactionHeadId] });
displayedHeroes: new List<HeroView> {
departedHero,
currentModel.Heroes[faction.FactionHeadId]
});
}
}
}
@@ -19,14 +19,19 @@ namespace eagle.Notifications.ARNNotifications {
var affectedProvinces = currentModel.ProvincesForFaction(imprisoningFactionId);
affectedProvinces.AddRange(currentModel.ProvincesForFaction(offeringFactionId));
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
var factionHeadId =
currentModel.MaybeDestroyedFaction(offeringFactionId).FactionHeadId;
var factionHead = currentModel.Heroes[factionHeadId];
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"You have rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"You have rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { factionHead });
@@ -34,10 +39,11 @@ namespace eagle.Notifications.ARNNotifications {
var factionHeadId =
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var factionHead = currentModel.Heroes[factionHeadId];
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name}!\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}}!\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { factionHead });
@@ -49,10 +55,11 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(offeringFactionId).FactionHeadId;
var factionHead2 = currentModel.Heroes[factionHeadId2];
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { factionHead1,
@@ -17,12 +17,17 @@ namespace eagle.Notifications.ARNNotifications {
var apprehended = currentModel.Heroes[details.ApprehendedHeroId];
string noteTitle = "Outlaw Apprehended";
string noteText =
$"{factionName} apprehended the outlaw {apprehended.Name} in {province.Name}.";
string textTemplate =
$"{factionName} apprehended the outlaw {{ApprehendedHero}} in {province.Name}.";
yield return new Notification(
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ApprehendedHero", (apprehended.NameTextId, "an outlaw") }
};
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: details.ProvinceId,
displayedHeroes: new List<HeroView> { factionHead, apprehended });
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using FactionId = Int32;
@@ -15,21 +16,26 @@ namespace eagle.Notifications.ARNNotifications {
var spottedOutlaw = currentModel.Heroes[details.OutlawHeroId];
string noteTitle = "";
string noteText = "";
string textTemplate = "";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "SpottedOutlaw", (spottedOutlaw.NameTextId, "An outlaw") }
};
if (province.RulingFactionId is FactionId fid && fid == currentModel.PlayerId) {
noteTitle = "Outlaw Spotted";
noteText = $"{spottedOutlaw.Name} has been spotted in {province.Name}!";
textTemplate = $"{{SpottedOutlaw}} has been spotted in {province.Name}!";
} else {
noteTitle = "Outlaw Departed";
noteText = $"{spottedOutlaw.Name} has left our territory for {province.Name}.";
textTemplate = $"{{SpottedOutlaw}} has left our territory for {province.Name}.";
}
yield return new Notification(
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: province.Id,
singleDisplayedHero: spottedOutlaw);
displayedHeroes: new List<HeroView> { spottedOutlaw });
}
}
}
@@ -18,12 +18,18 @@ namespace eagle.Notifications.ARNNotifications {
var hero2Faction = currentModel.FactionName(note.Hero2FactionId);
string noteTitle = "Prisoners Exchanged";
string noteText =
$"{hero1.Name} ({hero1Faction}) and {hero2.Name} ({hero2Faction}) have agreed to a prisoner exchange.";
string textTemplate =
$"{{Hero1}} ({hero1Faction}) and {{Hero2}} ({hero2Faction}) have agreed to a prisoner exchange.";
yield return new Notification(
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "Hero1", (hero1.NameTextId, "A hero") },
{ "Hero2", (hero2.NameTextId, "A hero") }
};
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
provinceIds: new List<
ProvinceId> { note.Hero1ProvinceId, note.Hero2ProvinceId },
displayedHeroes: new List<HeroView> { hero1, hero2 });
@@ -18,20 +18,35 @@ namespace eagle.Notifications.ARNNotifications {
.FactionHeadId];
string noteTitle = "Prisoner Executed";
string noteText;
string textTemplate;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (note.LastFactionId is {} lastFactionId) {
var lastFaction = currentModel.MaybeDestroyedFaction(lastFactionId);
noteText =
$"{executingFactionName} has executed {DisplayNames.MaybeFactionLeaderDescription(lastFaction, hero, currentModel)}!";
string victimDescription;
if (lastFaction.FactionHeadId == hero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (lastFaction.Leaders.Contains(hero.Id)) {
victimDescription =
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(hero.PronounGender)} {{HeroName}}";
} else {
victimDescription =
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"{executingFactionName} has executed {victimDescription}!";
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
} else {
noteText = $"{executingFactionName} has executed {hero.Name}!";
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!";
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
}
yield return new Notification(
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: note.ProvinceId,
displayedHeroes: new List<HeroView> { factionLeader, hero });
}
@@ -17,12 +17,17 @@ namespace eagle.Notifications.ARNNotifications {
var hero = currentModel.Heroes[details.HeroId];
var province = currentModel.Provinces[details.ProvinceId];
string noteText =
$"You failed to complete a {DisplayNames.QuestTypeString(details.FailedQuest)} quest for {hero.Name} in {province.Name}.\n\n";
string textTemplate =
$"You failed to complete a {DisplayNames.QuestTypeString(details.FailedQuest)} quest for {{QuestHero}} in {province.Name}.\n\n";
yield return Notification.StreamingNotification(
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "QuestHero", (hero.NameTextId, "the hero") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
title: noteTitle,
prefixedText: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { province.Id },
displayedHeroes: new List<HeroView> { hero });
@@ -17,12 +17,17 @@ namespace eagle.Notifications.ARNNotifications {
var hero = currentModel.Heroes[details.HeroId];
var province = currentModel.Provinces[details.ProvinceId];
string noteText =
$"You completed a {DisplayNames.QuestTypeString(details.FulfilledQuest)} quest for {hero.Name} in {province.Name}.\n\n";
string textTemplate =
$"You completed a {DisplayNames.QuestTypeString(details.FulfilledQuest)} quest for {{QuestHero}} in {province.Name}.\n\n";
yield return Notification.StreamingNotification(
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "QuestHero", (hero.NameTextId, "the hero") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
title: noteTitle,
prefixedText: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { province.Id },
displayedHeroes: new List<HeroView> { hero });
@@ -26,19 +26,25 @@ namespace eagle.Notifications.ARNNotifications {
var ransomedHeroId = notif.RansomedHeroId;
var ransomedHero = currentModel.Heroes[ransomedHeroId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "RansomedHero", (ransomedHero.NameTextId, "the hero") }
};
if (playerId.HasValue && playerId.Value == paidToFactionId) {
// no notification
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
prefixedText: $"{currentModel.FactionName(paidToFactionId)} has accepted our ransom for {ransomedHero.Name}.\n\n",
textTemplate: $"{currentModel.FactionName(paidToFactionId)} has accepted our ransom for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { acceptingFactionHead, ransomedHero });
} else {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Paid",
prefixedText: $"{currentModel.FactionName(paidByFactionId)} paid a ransom to {currentModel.FactionName(paidToFactionId)} for the return of {ransomedHero.Name}.\n\n",
textTemplate: $"{currentModel.FactionName(paidByFactionId)} paid a ransom to {currentModel.FactionName(paidToFactionId)} for the return of {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { acceptingFactionHead, offeringFactionHead, ransomedHero });
@@ -26,24 +26,31 @@ namespace eagle.Notifications.ARNNotifications {
var ransomedHeroId = notif.RansomedHeroId;
var ransomedHero = currentModel.Heroes[ransomedHeroId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "RansomedHero", (ransomedHero.NameTextId, "the hero") }
};
if (playerId is PlayerId pid && pid == refusingFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Rejected",
prefixedText: $"We have refused to accept the ransom from {currentModel.FactionName(offeringFactionId)} for {ransomedHero.Name}.\n\n",
textTemplate: $"We have refused to accept the ransom from {currentModel.FactionName(offeringFactionId)} for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Rejected",
prefixedText: $"{currentModel.FactionName(refusingFactionId)} has refused to accept our ransom for {ransomedHero.Name}.\n\n",
textTemplate: $"{currentModel.FactionName(refusingFactionId)} has refused to accept our ransom for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { refusingFactionHead, ransomedHero});
} else {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Rejected",
prefixedText: $"{currentModel.FactionName(refusingFactionId)} refused to accept a ransom to {currentModel.FactionName(offeringFactionId)} for the return of {ransomedHero.Name}.\n\n",
textTemplate: $"{currentModel.FactionName(refusingFactionId)} refused to accept a ransom to {currentModel.FactionName(offeringFactionId)} for the return of {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { refusingFactionHead, offeringFactionHead, ransomedHero });
@@ -17,13 +17,21 @@ namespace eagle.Notifications.ARNNotifications {
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
yield return Notification.StreamingNotification(
string textTemplate =
$"{{NewBrother}} has sworn brotherhood with {factionName}'s leader {{FactionHead}}.\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "NewBrother", (newBrother.NameTextId, "A hero") },
{ "FactionHead", (factionHead.NameTextId, "the leader") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Swore Brotherhood",
prefixedText:
$"{newBrother.Name} has sworn brotherhood with {factionName}'s leader {factionHead.Name}.\n\n",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { newBrother, factionHead});
displayedHeroes: new List<HeroView> { newBrother, factionHead });
}
}
}
@@ -27,27 +27,34 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var imprisoningFactionHead = currentModel.Heroes[imprisoningFactionHeadId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"You have rejected a treaty with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"You have rejected a treaty with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { offeringFactionHead, imprisonedHero});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name}!\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}}!\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, imprisonedHero});
} else {
yield return Notification.StreamingNotification(
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ambassador Imprisoned",
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected a truce offer from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected a truce offer from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, offeringFactionHead, imprisonedHero });
@@ -20,12 +20,17 @@ namespace eagle.Notifications.ARNNotifications {
.FactionHeadId];
string noteTitle = "Vassal Exiled";
string noteText =
$"{factionName} sent {exiledHero.Name} into exile from {province.Name}\n\n";
string textTemplate =
$"{factionName} sent {{ExiledHero}} into exile from {province.Name}\n\n";
yield return Notification.StreamingNotification(
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ExiledHero", (exiledHero.NameTextId, "the vassal") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
title: noteTitle,
prefixedText: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { factionHead, exiledHero });
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications {
using ProvinceId = Int32;
public class DynamicTextNotification : Notification, IDisposable {
private List<GeneratedTextListener> textListeners = new();
private string textTemplate;
private Dictionary<string, string> placeholderValues = new();
public DynamicTextNotification(
string title,
string textTemplate,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders,
List<ProvinceId> provinceIds,
List<HeroView> displayedHeroes,
bool shouldAppend = true,
string llmId = null)
: base(title,
BuildInitialText(textTemplate, heroPlaceholders),
provinceIds,
displayedHeroes,
shouldAppend,
llmId) {
this.textTemplate = textTemplate;
SetupTextListeners(heroPlaceholders);
}
public DynamicTextNotification(
string title,
string textTemplate,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders,
ProvinceId singleProvinceId,
List<HeroView> displayedHeroes,
bool shouldAppend = true)
: this(title,
textTemplate,
heroPlaceholders,
new List<ProvinceId> { singleProvinceId },
displayedHeroes,
shouldAppend) {}
private static string BuildInitialText(
string template,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders) {
string result = template;
foreach (var kvp in heroPlaceholders) {
string placeholder = kvp.Key;
string fallback = kvp.Value.fallback;
// Try to get text immediately, fall back if not available
string currentText = fallback;
if (!string.IsNullOrEmpty(kvp.Value.nameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(kvp.Value.nameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
currentText = textEntry.Text;
}
}
result = result.Replace($"{{{placeholder}}}", currentText);
}
return result;
}
private void SetupTextListeners(
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders) {
int index = 0;
foreach (var kvp in heroPlaceholders) {
string placeholder = kvp.Key;
string nameTextId = kvp.Value.nameTextId;
string fallback = kvp.Value.fallback;
if (!string.IsNullOrEmpty(nameTextId)) {
var listener = new GeneratedTextListener(
nameTextId,
"",
"",
index,
(listenerIndex, text) => UpdatePlaceholder(placeholder, text));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
index++;
}
}
private void UpdatePlaceholder(string placeholder, string newText) {
placeholderValues[placeholder] = newText;
UpdateText();
}
private void UpdateText() {
string result = textTemplate;
foreach (var kvp in placeholderValues) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
// Use reflection to update the private setter
var textProperty = typeof(Notification).GetProperty("Text");
textProperty?.SetValue(this, result);
}
public void Dispose() {
foreach (var listener in textListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
textListeners.Clear();
}
public static DynamicTextNotification StreamingDynamicNotification(
string title,
string textTemplate,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders,
string llmId,
List<ProvinceId> provinceIds,
List<HeroView> displayedHeroes) {
return new DynamicTextNotification(
title: title,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
provinceIds: provinceIds,
displayedHeroes: displayedHeroes,
shouldAppend: false,
llmId: llmId);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 025d7dcb002bb4e5ca00edcc77689b6d
@@ -8,12 +8,20 @@ namespace eagle.Notifications {
private static IEnumerable<Notification> GenerateNotification(
ActionResultView actionResultView,
IGameModel currentModel) {
yield return new Notification(
var hero = currentModel.Heroes[actionResultView.Leader.Value];
string textTemplate =
"{DecliningHero} feels you are too different to swear eternal brotherhood.";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "DecliningHero", (hero.NameTextId, "The hero") }
};
yield return new DynamicTextNotification(
title: "Oath Declined",
text:
$"{currentModel.Heroes[actionResultView.Leader.Value].Name} feels you are too different to swear eternal brotherhood.",
singleProvinceId: actionResultView.GameStateDiff
.ChangedProvinces[0].Id);
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: actionResultView.GameStateDiff.ChangedProvinces[0].Id,
displayedHeroes: new List<HeroView> { hero });
}
}
}
@@ -22,13 +22,19 @@ namespace eagle.Notifications {
var province = currentModel.Provinces[cp.Id];
string noteTitle = "Prisoner Escaped";
string noteText = $"{hero.Name} has escaped from {province.Name}!";
string textTemplate = $"{{EscapedHero}} has escaped from {province.Name}!";
yield return new Notification(
var heroPlaceholders =
new Dictionary<string, (string nameTextId, string fallback)> {
{ "EscapedHero", (hero.NameTextId, "A prisoner") }
};
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: cp.Id,
singleDisplayedHero: hero);
displayedHeroes: new List<HeroView> { hero });
}
}
}
@@ -19,12 +19,21 @@ namespace eagle.Notifications {
var hero = currentModel.Heroes[ch.Id];
const string noteTitle = "Prisoner Returned";
string noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has returned {hero.Name} to us!";
string textTemplate =
$"{{ActingFactionLeader}} has returned {{ReturnedHero}} to us!";
yield return new Notification(
var heroPlaceholders =
new Dictionary<string, (string nameTextId, string fallback)> {
{ "ReturnedHero", (hero.NameTextId, "our hero") },
{ "ActingFactionLeader",
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name) }
};
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { actingFactionLeader, hero });
}
@@ -19,15 +19,17 @@ namespace eagle.Notifications {
leader = currentModel.Heroes[rulingHid];
}
string noteTitle = "Riot";
string noteText = $"A riot seems to have occurred in {province.Name}.";
string baseText = $"A riot seems to have occurred in {province.Name}.";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
HeroView killedHero = null;
if (province.RulingFactionId.HasValue && province.RulingFactionId.Value == playerId) {
noteText = $"A terrible riot has occurred in {province.Name}.";
baseText = $"A terrible riot has occurred in {province.Name}.";
foreach (var removedHeroId in actionResultView.GameStateDiff.RemovedHeroIds) {
var hero = currentModel.Heroes[removedHeroId];
noteText += "\n";
noteText += $"{hero.Name} was killed in the riot!";
baseText += "\n";
baseText += $"{{KilledHero}} was killed in the riot!";
heroPlaceholders["KilledHero"] = (hero.NameTextId, "A hero");
killedHero = hero;
if (killedHero.Id == leader.Id) killedHero = null;
}
@@ -36,9 +38,10 @@ namespace eagle.Notifications {
var displayedHeroes =
new List<HeroView> { leader, killedHero }.Where(x => x != null).ToList();
yield return new Notification(
yield return new DynamicTextNotification(
title: noteTitle,
text: noteText,
textTemplate: baseText,
heroPlaceholders: heroPlaceholders,
singleProvinceId: province.Id,
displayedHeroes: displayedHeroes);
}
@@ -28,7 +28,7 @@ namespace eagle.Notifications {
string noteTitle = "Army Turned Back";
string noteText =
$"{DisplayNames.FactionString(faction, currentModel)}'s army approaching {province.Name} was forced to turn back due to harsh weather.";
$"{DisplayNames.ResolvedFactionString(faction, currentModel)}'s army approaching {province.Name} was forced to turn back due to harsh weather.";
yield return new Notification(
title: noteTitle,
@@ -492,11 +492,6 @@ namespace eagle {
}
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.HeadshotResponse:
AWSResourceFetcher.headshotFetcher.ReceiveHeadshotResponse(
current.HeadshotResponse);
break;
}
sc = _streamingCall;
@@ -92,7 +92,7 @@ namespace eagle {
if (imagesAndNamesContainer) imagesAndNamesContainer.gameObject.SetActive(true);
var firstHero = PopupInfos[DisplayedIndex].displayedHeroes[0];
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
firstImage,
firstHero.ImagePath);
firstImage.transform.parent.gameObject.SetActive(true);
@@ -100,7 +100,7 @@ namespace eagle {
if (imageCount > 1) {
var secondHero = PopupInfos[DisplayedIndex].displayedHeroes[1];
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
secondImage,
secondHero.ImagePath);
secondImage.transform.parent.gameObject.SetActive(true);
@@ -108,7 +108,7 @@ namespace eagle {
if (imageCount > 2) {
var thirdHero = PopupInfos[DisplayedIndex].displayedHeroes[2];
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
thirdImage,
thirdHero.ImagePath);
thirdImage.transform.parent.gameObject.SetActive(true);
@@ -94,13 +94,14 @@ namespace eagle {
public DisclosureTriangle dominionViewToggle;
private ProvinceView _provinceView;
private DynamicFactionTextUpdater factionTextUpdater = new DynamicFactionTextUpdater();
private void SetHeadshotToHeroId(HeroId hid) {
var rulingHero = _model.Heroes[hid];
if (rulingHero.ImagePath is String path) {
HeadshotImage.gameObject.SetActive(true);
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, path);
ResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, path);
} else {
HeadshotImage.gameObject.SetActive(false);
}
@@ -314,12 +315,10 @@ namespace eagle {
rulingHeroPrefix = "UNKNOWN";
}
var factionLabel = DisplayNames.FactionString(faction, Model);
ProvinceNameField.text = Province.Name;
provinceLeaderField.TextId = rulingHeroNameTextId;
provinceLeaderField.PrefixedText = rulingHeroPrefix;
provinceFactionField.text = factionLabel;
factionTextUpdater.SetFactionText(provinceFactionField, faction, Model);
var diploStrings = new List<string>();
if (!hasFullInfo) {
@@ -400,5 +399,7 @@ namespace eagle {
dominionViewToggle.isOn = !dominionViewToggle.isOn;
}
}
private void OnDestroy() { factionTextUpdater?.Dispose(); }
}
}
@@ -4,7 +4,7 @@ using UnityEngine;
using UnityEngine.UI;
namespace eagle {
public class CombatUnitRowController : TableRowController {
public class CombatUnitRowController : TableRowController, System.IDisposable {
public RawImage professionImage;
public TMP_Text heroNameLabel;
public RawImage battalionImage;
@@ -12,11 +12,13 @@ namespace eagle {
public TMP_Text trainingLabel;
public TMP_Text armamentLabel;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public HeroView Hero {
set {
professionImage.texture = CommonTextures.Profession(value.Profession);
professionImage.color = professionImage.texture == null ? Color.clear : Color.white;
heroNameLabel.text = value.Name;
textUpdater.SetHeroText(heroNameLabel, value.NameTextId, "Hero");
}
}
@@ -37,5 +39,9 @@ namespace eagle {
battalionImage.color = battalionImage.texture == null ? Color.clear : Color.white;
}
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -67,7 +67,7 @@ namespace eagle {
public CommonUnit Unit {
get {
var hero = new CommonHero {
Name = heroName.text,
NameTextId = heroName.text,
Profession = SelectedProfession,
Strength = int.Parse(strength.text),
Agility = int.Parse(agility.text),
@@ -102,7 +102,7 @@ namespace eagle {
};
}
set {
heroName.text = value.Hero.Name;
heroName.text = value.Hero.NameTextId;
professionDropdown.value = value.Hero.Profession == CommonProfession.NoProfession
? 0
: (int)value.Hero.Profession;
@@ -12,7 +12,8 @@ namespace eagle {
public class DominionTableRowController : TableRowController,
IPointerEnterHandler,
IPointerExitHandler,
IPointerClickHandler {
IPointerClickHandler,
System.IDisposable {
public TMP_Text provinceName;
public TMP_Text ruler;
public TMP_Text support;
@@ -34,6 +35,7 @@ namespace eagle {
public GameObject flood;
private ProvinceId ProvinceId;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
private void SetDevastatedValue(TMP_Text textField, double baseValue, double devastation) {
if (devastation == 0.0) {
@@ -61,7 +63,8 @@ namespace eagle {
provinceName.color = Color.black;
}
ruler.text = model.Heroes[province.FullInfo.RulingHeroId.Value].Name;
var rulingHero = model.Heroes[province.FullInfo.RulingHeroId.Value];
textUpdater.SetHeroText(ruler, rulingHero.NameTextId, "Ruler");
support.text = GUIUtils.ConditionString(province.FullInfo.Support);
commanderCount.text = province.FullInfo.RulingFactionHeroIds.Count.ToString();
battalionCount.text = province.FullInfo.Battalions.Count.ToString();
@@ -156,5 +159,9 @@ namespace eagle {
public void OnPointerClick(PointerEventData eventData) {
GetComponentInParent<DominionPanelController>().RowClicked(ProvinceId);
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
namespace eagle {
public class DynamicFactionTextUpdater : IDisposable {
private List<GeneratedTextListener> textListeners = new();
private Dictionary<string, TMP_Text> textComponents = new();
private Dictionary<string, FactionTextConfig> factionConfigs = new();
private class FactionTextConfig {
public FactionView Faction { get; }
public IGameModel GameModel { get; }
public string Template { get; }
public string FallbackText { get; }
public FactionTextConfig(
FactionView faction,
IGameModel gameModel,
string template,
string fallbackText) {
Faction = faction;
GameModel = gameModel;
Template = template;
FallbackText = fallbackText;
}
}
public void SetFactionText(
TMP_Text textComponent,
FactionView faction,
IGameModel gameModel,
string template = null,
string fallbackText = null) {
if (!textComponent || faction == null || gameModel == null) return;
string componentKey = textComponent.GetInstanceID().ToString();
textComponents[componentKey] = textComponent;
// Use the provided template or get the default faction string template
string actualTemplate =
template ?? DisplayNames.FactionStringTemplate(faction, gameModel);
string actualFallback = fallbackText ?? faction.Name;
factionConfigs[componentKey] =
new FactionTextConfig(faction, gameModel, actualTemplate, actualFallback);
// Set initial text
UpdateFactionText(componentKey);
// If the template contains {FactionLeaderName}, subscribe to leader name updates
if (actualTemplate.Contains("{FactionLeaderName}")) {
var leader = gameModel.Heroes[faction.FactionHeadId];
if (!string.IsNullOrEmpty(leader.NameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry == null || string.IsNullOrEmpty(textEntry.Text)) {
// Create listener for dynamic updates
var listener = new GeneratedTextListener(
leader.NameTextId,
"",
"",
0,
(index, text) => UpdateFactionText(componentKey));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
}
}
public void SetFactionTextWithCustomFormat(
TMP_Text textComponent,
FactionView faction,
IGameModel gameModel,
string customFormat,
string fallbackText = null) {
if (!textComponent || faction == null || gameModel == null ||
string.IsNullOrEmpty(customFormat))
return;
string componentKey = textComponent.GetInstanceID().ToString();
textComponents[componentKey] = textComponent;
string actualFallback = fallbackText ?? faction.Name;
factionConfigs[componentKey] =
new FactionTextConfig(faction, gameModel, customFormat, actualFallback);
// Set initial text
UpdateFactionTextWithCustomFormat(componentKey, customFormat);
// If the custom format contains {FactionLeaderName}, subscribe to leader name updates
if (customFormat.Contains("{FactionLeaderName}")) {
var leader = gameModel.Heroes[faction.FactionHeadId];
if (!string.IsNullOrEmpty(leader.NameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry == null || string.IsNullOrEmpty(textEntry.Text)) {
// Create listener for dynamic updates
var listener = new GeneratedTextListener(
leader.NameTextId,
"",
"",
0,
(index, text) => UpdateFactionTextWithCustomFormat(
componentKey,
customFormat));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
}
}
private void UpdateFactionText(string componentKey) {
if (!textComponents.TryGetValue(componentKey, out var component) ||
!factionConfigs.TryGetValue(componentKey, out var config) || component == null) {
return;
}
var template = config.Template;
if (template.Contains("{FactionLeaderName}")) {
var leader = config.GameModel.Heroes[config.Faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
component.text = template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
component.text = template.Replace("{FactionLeaderName}", "Leader");
}
} else {
component.text = template;
}
}
private void UpdateFactionTextWithCustomFormat(string componentKey, string customFormat) {
if (!textComponents.TryGetValue(componentKey, out var component) ||
!factionConfigs.TryGetValue(componentKey, out var config) || component == null) {
return;
}
string resolvedText = customFormat;
if (customFormat.Contains("{FactionLeaderName}")) {
var leader = config.GameModel.Heroes[config.Faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
resolvedText = resolvedText.Replace("{FactionLeaderName}", textEntry.Text);
} else {
resolvedText = resolvedText.Replace("{FactionLeaderName}", "Leader");
}
}
if (customFormat.Contains("{FactionName}")) {
resolvedText = resolvedText.Replace("{FactionName}", config.Faction.Name);
}
if (customFormat.Contains("{Prestige}")) {
resolvedText =
resolvedText.Replace("{Prestige}", config.Faction.Prestige.ToString());
}
component.text = resolvedText;
}
public void Dispose() {
foreach (var listener in textListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
textListeners.Clear();
textComponents.Clear();
factionConfigs.Clear();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 73a4160390d2f45938cfcd0c2cc05b21
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
namespace eagle {
public class DynamicHeroTextUpdater : IDisposable {
private List<GeneratedTextListener> textListeners = new();
private Dictionary<string, TMP_Text> textComponents = new();
private Dictionary<string, string> fallbackTexts = new();
public void SetHeroText(TMP_Text textComponent, string nameTextId, string fallbackText) {
if (!textComponent) return;
string componentKey = textComponent.GetInstanceID().ToString();
textComponents[componentKey] = textComponent;
fallbackTexts[componentKey] = fallbackText;
// Set initial text
if (!string.IsNullOrEmpty(nameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(nameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
textComponent.text = textEntry.Text;
} else {
textComponent.text = fallbackText;
// Create listener for dynamic updates
var listener = new GeneratedTextListener(
nameTextId,
"",
"",
0,
(index, text) => UpdateComponentText(componentKey, text));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
} else {
textComponent.text = fallbackText;
}
}
private void UpdateComponentText(string componentKey, string newText) {
if (textComponents.TryGetValue(componentKey, out var component) && component != null) {
component.text = newText;
}
}
public void Dispose() {
foreach (var listener in textListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
textListeners.Clear();
textComponents.Clear();
fallbackTexts.Clear();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6efb8db8ffa154bdaac3cb4297833938
@@ -4,7 +4,7 @@ using UnityEngine;
using UnityEngine.UI;
namespace eagle {
public class ExchangedHeroRowController : TableRowController {
public class ExchangedHeroRowController : TableRowController, System.IDisposable {
public RawImage professionImage;
public TMP_Text heroNameLabel;
public TMP_Text strLabel;
@@ -19,6 +19,8 @@ namespace eagle {
public RawImage prisonerImage;
public TMP_Text lastFactionLabel;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public void SetHostage(HeroView hero, string factionName) {
SetHero(hero, factionName);
prisonerImage.gameObject.SetActive(false);
@@ -38,7 +40,7 @@ namespace eagle {
} else {
professionImage.color = Color.white;
}
heroNameLabel.text = hero.Name;
textUpdater.SetHeroText(heroNameLabel, hero.NameTextId, "Hero");
if (hero.Strength > 0) {
strLabel.text = hero.Strength.ToString();
@@ -59,5 +61,9 @@ namespace eagle {
lastFactionLabel.text = factionName;
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -5,7 +5,7 @@ using UnityEngine;
using UnityEngine.UI;
namespace eagle {
public class HeroRowController : TableRowController {
public class HeroRowController : TableRowController, System.IDisposable {
public RawImage professionImage;
public TMP_Text heroNameLabel;
public TMP_Text strengthLabel;
@@ -23,17 +23,19 @@ namespace eagle {
public RawImage archeryImage;
public RawImage fireImage;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public HeroView Hero {
set {
professionImage.texture =
gameObject.GetComponentInParent<EagleCommonTextures>().Profession(
value.Profession);
if (professionImage.texture == null) {
if (!professionImage.texture) {
professionImage.color = Color.clear;
} else {
professionImage.color = Color.white;
}
heroNameLabel.text = value.Name;
textUpdater.SetHeroText(heroNameLabel, value.NameTextId, "Hero");
// not ideal -- this is just so we don't show 0s for scouted heroes
if (value.Strength != 0) {
@@ -70,5 +72,9 @@ namespace eagle {
}
}
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -2,9 +2,19 @@ using TMPro;
using UnityEngine.UI;
namespace eagle {
public class MovingArmyPopupRowController : TableRowController {
public class MovingArmyPopupRowController : TableRowController, System.IDisposable {
public TMP_Text HeroName;
public RawImage BattalionImage;
public TMP_Text BattalionSize;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public void SetHeroText(string nameTextId, string fallbackText) {
textUpdater.SetHeroText(HeroName, nameTextId, fallbackText);
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -5,7 +5,7 @@ using UnityEngine.Events;
using UnityEngine.UI;
namespace eagle {
public class SelectedUnitRowController : TableRowController {
public class SelectedUnitRowController : TableRowController, System.IDisposable {
public RawImage professionImage;
public RawImage archeryImage;
public RawImage fireImage;
@@ -19,6 +19,8 @@ namespace eagle {
public RawImage armamentImage;
public TMP_Text battalionNameLabel;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public HeroView Hero {
set {
archeryImage.gameObject.SetActive(false);
@@ -31,7 +33,7 @@ namespace eagle {
professionImage.texture =
gameObject.GetComponentInParent<EagleCommonTextures>().Profession(
value.Profession);
heroNameLabel.text = value.Name;
textUpdater.SetHeroText(heroNameLabel, value.NameTextId, "Hero");
archeryImage.gameObject.SetActive(value.ArcheryCapable);
fireImage.gameObject.SetActive(value.StartFireCapable);
}
@@ -107,5 +109,9 @@ namespace eagle {
armamentImage.GetComponentInChildren<TableRowClickDetector>().selectable = true;
}
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -1,11 +1,14 @@
using Net.Eagle0.Eagle.Common;
using System;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using static Net.Eagle0.Eagle.Common.QuestDetails;
using System.Linq;
namespace eagle {
public class UnaffiliatedHeroRowController : TableRowController {
public class UnaffiliatedHeroRowController : TableRowController, System.IDisposable {
public RawImage professionImage;
public TMP_Text heroNameLabel;
public TMP_Text questLabel;
@@ -18,6 +21,9 @@ namespace eagle {
public RawImage archeryImage;
public RawImage fireImage;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
private GeneratedTextListener questTextListener;
public void SetUnaffiliatedHero(UnaffiliatedHeroBasics uh, IGameModel model) {
professionImage.texture =
gameObject.GetComponentInParent<EagleCommonTextures>().Profession(
@@ -27,12 +33,13 @@ namespace eagle {
} else {
professionImage.color = Color.white;
}
heroNameLabel.text = uh.Name;
// Use the NameTextId from UnaffiliatedHeroBasics
textUpdater.SetHeroText(heroNameLabel, uh.NameTextId, "Hero");
switch (uh.RecruitmentInfo.Status) {
case RecruitmentStatus.HasQuest:
questLabel.text =
DisplayNames.ShortQuestString(uh.RecruitmentInfo.Quest, model);
SetQuestText(uh.RecruitmentInfo.Quest, model);
break;
default:
questLabel.text = DisplayNames.ShortRecruitmentStatusString(uh.RecruitmentInfo);
@@ -60,5 +67,176 @@ namespace eagle {
}
}
}
public void Dispose() {
textUpdater?.Dispose();
if (questTextListener != null) {
ClientTextProvider.Provider.RemoveListener(questTextListener);
questTextListener = null;
}
}
private void OnDestroy() { Dispose(); }
private void SetQuestText(Quest quest, IGameModel model) {
if (quest == null) {
questLabel.text = "There is nothing you can do";
return;
}
// Handle the special case of DismissSpecificVassalQuest with dynamic text
if (quest.Details.SealedValueCase == SealedValueOneofCase.DismissSpecificVassalQuest) {
var targetHeroId = quest.Details.DismissSpecificVassalQuest.TargetHeroId;
var targetHero = model.Heroes[targetHeroId];
// Check if the name is available immediately
var textEntry = ClientTextProvider.Provider.GetTextEntry(targetHero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
questLabel.text = $"Dismiss {textEntry.Text}";
} else {
// Set fallback text and create listener for dynamic updates
questLabel.text = "Dismiss vassal";
// Clean up any existing listener
if (questTextListener != null) {
ClientTextProvider.Provider.RemoveListener(questTextListener);
}
// Create new listener that prefixes "Dismiss " to the hero name
questTextListener = new GeneratedTextListener(
targetHero.NameTextId,
"",
"",
0,
(index, heroName) => {
if (questLabel != null) { questLabel.text = $"Dismiss {heroName}"; }
});
ClientTextProvider.Provider.AddListener(questTextListener);
}
return;
}
// For all other quests, use the static method
questLabel.text = ShortQuestString(quest, model);
}
static string ShortQuestString(Quest quest, IGameModel model) {
if (quest == null) { return $"There is nothing you can do"; }
switch (quest.Details.SealedValueCase) {
case SealedValueOneofCase.AllianceQuest:
return $"Secure an alliance with another faction";
case SealedValueOneofCase.DefeatFactionQuest:
return $"Defeat {model.FactionName(quest.Details.DefeatFactionQuest.TargetFactionId)}";
case SealedValueOneofCase.AlmsToProvinceQuest: {
var province = model.Provinces[quest.Details.AlmsToProvinceQuest.ProvinceId];
var desiredFood = quest.Details.AlmsToProvinceQuest.TotalFood;
var remainingFood = Math.Max(0, desiredFood - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingFood} remaining)" : "";
return $"Give {desiredFood} food in alms in {province.Name}{remainingText}";
}
case SealedValueOneofCase.AlmsAcrossRealmQuest: {
var desiredFood = quest.Details.AlmsAcrossRealmQuest.TotalFood;
var remainingFood = Math.Max(0, desiredFood - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingFood} remaining)" : "";
return $"Give {desiredFood} food in alms across the realm{remainingText}";
}
case SealedValueOneofCase.GiveToHeroesInProvinceQuest: {
var province =
model.Provinces[quest.Details.GiveToHeroesInProvinceQuest.ProvinceId];
var desiredGold = quest.Details.GiveToHeroesInProvinceQuest.TotalGold;
var remainingGold = Math.Max(0, desiredGold - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingGold} remaining)" : "";
return $"Give {desiredGold} gold to heroes in {province.Name}{remainingText}";
}
case SealedValueOneofCase.GiveToHeroesAcrossRealmQuest: {
var desiredGold = quest.Details.GiveToHeroesAcrossRealmQuest.TotalGold;
var remainingGold = Math.Max(0, desiredGold - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingGold} remaining)" : "";
return $"Give {desiredGold} gold to heroes across the realm{remainingText}";
}
case SealedValueOneofCase.ImproveAgricultureQuest:
return $"Raise Agriculture to {(int)Math.Ceiling(quest.Details.ImproveAgricultureQuest.DesiredValue)}";
case SealedValueOneofCase.ImproveEconomyQuest:
return $"Raise Economy to {(int)Math.Ceiling(quest.Details.ImproveEconomyQuest.DesiredValue)}";
case SealedValueOneofCase.ImproveInfrastructureQuest:
return $"Raise Infrastructure to {(int)Math.Ceiling(quest.Details.ImproveInfrastructureQuest.DesiredValue)}";
case SealedValueOneofCase.SpecificExpansionQuest:
return $"Expand to {model.Provinces[quest.Details.SpecificExpansionQuest.ProvinceId].Name}";
case SealedValueOneofCase.DismissSpecificVassalQuest:
// This case is now handled by SetQuestText with dynamic updates
return "Dismiss vassal";
case SealedValueOneofCase.WealthQuest:
return $"Acquire {quest.Details.WealthQuest.Gold} gold and {quest.Details.WealthQuest.Food} food";
case SealedValueOneofCase.UpgradeBattalionQuest:
return $"Raise a {DisplayNames.LongBattalionTypeNames[quest.Details.UpgradeBattalionQuest.BattalionTypeId]} to {(int)Math.Ceiling(quest.Details.UpgradeBattalionQuest.MinimumTraining)} training and {(int)Math.Ceiling(quest.Details.UpgradeBattalionQuest.MinimumArmament)} armament";
case SealedValueOneofCase.GrandArmyQuest:
return $"Have {quest.Details.GrandArmyQuest.TotalTroopCount} troops";
case SealedValueOneofCase.TruceWithFactionQuest:
return $"Sign a truce with {model.FactionName(quest.Details.TruceWithFactionQuest.TargetFactionId)}";
case SealedValueOneofCase.TruceCountQuest: {
var currentTruceString = "";
if (model.PlayerId is int fid) {
var currentTruces = model.ActiveFactions[fid].FactionRelationships.Count(
fr => fr.RelationshipLevel !=
FactionRelationshipView.Types.RelationshipLevel.Hostile);
if (currentTruces == 0) {
currentTruceString = " (currently have none)";
} else {
currentTruceString = " (currently have " + currentTruces + ")";
}
}
return $"Have truces with {quest.Details.TruceCountQuest.TruceCount} factions" +
currentTruceString;
}
case SealedValueOneofCase.ExecutePrisonerQuest: {
var details = quest.Details.ExecutePrisonerQuest;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Execute prisoner in {provinceName}";
}
case SealedValueOneofCase.ExilePrisonerQuest: {
var details = quest.Details.ExilePrisonerQuest;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Exile prisoner from {provinceName}";
}
case SealedValueOneofCase.FreePrisonerQuest: {
var details = quest.Details.FreePrisonerQuest;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Release prisoner from prison in {provinceName}";
}
case SealedValueOneofCase.ReturnPrisonerQuest: {
var details = quest.Details.ReturnPrisonerQuest;
var provinceName = model.Provinces[details.ProvinceId].Name;
var factionName = model.FactionName(details.ToFactionId);
return $"Return prisoner from {provinceName} to {factionName}";
}
default:
return $"Crosby hasn't gotten to quest of type {quest.Details.SealedValueCase} yet";
}
}
}
}
@@ -3,22 +3,28 @@ using TMPro;
using UnityEngine.UI;
namespace eagle {
public class UnitSelectorHeroRowController : TableRowController {
public class UnitSelectorHeroRowController : TableRowController, System.IDisposable {
public RawImage professionImage;
public RawImage archeryImage;
public RawImage fireImage;
public TMP_Text heroNameLabel;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public HeroView Hero {
set {
professionImage.texture =
gameObject.GetComponentInParent<EagleCommonTextures>().Profession(
value.Profession);
heroNameLabel.text = value.Name;
textUpdater.SetHeroText(heroNameLabel, value.NameTextId, "Hero");
archeryImage.gameObject.SetActive(value.ArcheryCapable);
fireImage.gameObject.SetActive(value.StartFireCapable);
professionImage.gameObject.SetActive(professionImage.texture != null);
}
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -11,6 +11,7 @@ using Net.Eagle0.Eagle.Api;
public class EagleConnection : IDisposable {
public readonly string playerName;
public readonly Metadata credentials;
public readonly string authHeader;
private const double KeepAliveSeconds = 45.0;
public static CancellationToken EagleCancellationToken =>
@@ -44,6 +45,7 @@ public class EagleConnection : IDisposable {
public EagleConnection(string playerName, string password, string url) {
this.playerName = playerName;
credentials = new Metadata { { "user", playerName } };
authHeader = AuthInterceptor.HeaderValue(playerName, password);
EagleGrpcClient = new Eagle.EagleClient(MakeInvoker(playerName, password, url));
}
@@ -191,7 +191,7 @@ namespace Shardok {
unitsById[unit.CommandingUnitId.Value];
nameLabel.text = $"[[{commandingUnit.Name ?? "??"}]]";
if (!string.IsNullOrEmpty(commandingUnit.HeadshotPath)) {
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
headshotImage,
commandingUnit.HeadshotPath);
}
@@ -205,7 +205,7 @@ namespace Shardok {
} else {
HeroView hero = unit.AttachedHero;
if (!string.IsNullOrEmpty(unit.HeadshotPath)) {
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
headshotImage,
unit.HeadshotPath);
}
@@ -1,32 +1,35 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using eagle;
using Google.Protobuf;
using Net.Eagle0.Eagle.Api;
using UnityEngine;
using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace common {
public class AWSResourceFetcher {
public static AWSResourceFetcher headshotFetcher = null;
public class ResourceFetcher {
public static ResourceFetcher headshotFetcher = null;
public static void SetUpConnection(PersistentClientConnection persistentConnection) {
headshotFetcher = new("headshots", persistentConnection);
public static void SetUpConnection(HttpClient httpClient) {
headshotFetcher = new("headshots", httpClient);
}
private readonly HttpClient _httpClient;
private readonly PersistentClientConnection _persistentClientConnection;
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
AWSResourceFetcher(
string relativeLocalPath,
PersistentClientConnection persistentClientConnection) {
ResourceFetcher(string relativeLocalPath, HttpClient httpClient) {
_localPath = Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
relativeLocalPath);
Directory.CreateDirectory(_localPath);
_persistentClientConnection = persistentClientConnection;
_httpClient = httpClient;
}
private readonly string _localPath;
@@ -59,12 +62,14 @@ namespace common {
}
}
private void FetchRemote(string path) {
var headshotRequest = new HeadshotRequest();
headshotRequest.Paths.Add(new HeadshotRequest.Types.SingleRequest { Path = path });
private async Task<bool> FetchRemote(string path) {
// Send the request to the server to fetch the headshot
var response = await _httpClient.SendAsync(new HttpRequestMessage {
Method = HttpMethod.Get,
RequestUri = new Uri(BaseUri, path)
});
_persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest { HeadshotRequest = headshotRequest });
return await ReceiveHeadshot(response.Content, path);
}
private class ImageLoadQueueItem {
@@ -74,24 +79,23 @@ namespace common {
readonly List<ImageLoadQueueItem> _imageLoadQueue = new();
public void ReceiveHeadshotResponse(HeadshotResponse response) {
foreach (var singleResponse in response.HeadshotResponses) {
SaveLocal(singleResponse.Path, singleResponse.ImageData);
private async Task<bool> ReceiveHeadshot(HttpContent response, string path) {
var buf = await response.ReadAsByteArrayAsync();
SaveLocal(path, ByteString.CopyFrom(buf));
lock (_imageLoadQueue) {
if (_imageLoadQueue.Count > 0) {
var elt =
_imageLoadQueue.FirstOrDefault(x => x.path == singleResponse.Path);
lock (_imageLoadQueue) {
if (_imageLoadQueue.Count > 0) {
var elt = _imageLoadQueue.FirstOrDefault(x => x.path == path);
if (elt != null) {
_imageLoadQueue.Remove(elt);
if (elt != null) {
_imageLoadQueue.Remove(elt);
MainQueue.Q.Enqueue(
() => LoadIntoRawImage(elt.image, singleResponse.Path));
}
MainQueue.Q.Enqueue(() => LoadIntoRawImage(elt.image, path));
}
}
}
return true;
}
// This must be called from the main thread
@@ -126,26 +130,15 @@ namespace common {
if (pathToFetch != null) { FetchRemote(pathToFetch); }
}
private void PrefetchBatch(IEnumerable<string> paths) {
var request = new HeadshotRequest();
request.Paths.AddRange(
paths.Select(path => new HeadshotRequest.Types.SingleRequest { Path = path }));
_persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest { HeadshotRequest = request });
}
const int MaxFetchCount = 10;
public void Prefetch(IEnumerable<string> paths) {
var toFetch = paths.Where(path => !File.Exists(FullLocalPath(path))).ToList();
if (toFetch.Any()) {
if (toFetch.Count() <= MaxFetchCount) {
PrefetchBatch(toFetch);
} else {
PrefetchBatch(toFetch.Take(MaxFetchCount));
Prefetch(toFetch.Skip(MaxFetchCount));
foreach (var path in toFetch) {
if (string.IsNullOrEmpty(path)) {
Debug.LogWarning("ResourceFetcher: Attempted to prefetch an empty path.");
}
FetchRemote(path);
}
}
}
@@ -0,0 +1,147 @@
# 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.
@@ -0,0 +1,144 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Win32;
namespace EagleInstaller {
public class Credentials {
public string Username { get; set; }
public string Password { get; set; }
public string ServerUrl { get; set; }
}
public class CredentialManager {
private const string RegistryKeyPath = @"SOFTWARE\Eagle0\Launcher";
private const string UsernameValueName = "Username";
private const string ServerUrlValueName = "ServerUrl";
private const string CredentialFileName = "eagle0_credentials.dat";
private static readonly string CredentialFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"eagle0",
CredentialFileName);
/// <summary>
/// Loads saved credentials. Returns null if no valid credentials are found.
/// </summary>
public static Credentials LoadCredentials() {
try {
// Try to load from registry (username and server URL)
string username = null;
string serverUrl = null;
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false)) {
if (key != null) {
username = key.GetValue(UsernameValueName) as string;
serverUrl = key.GetValue(ServerUrlValueName) as string;
}
}
if (string.IsNullOrEmpty(username)) { return null; }
// Try to load encrypted password from file
string password = LoadEncryptedPassword();
if (string.IsNullOrEmpty(password)) { return null; }
return new Credentials {
Username = username,
Password = password,
ServerUrl = serverUrl ?? "https://eagle0.net"
};
} catch (Exception) {
// If anything fails, return null to prompt for credentials
return null;
}
}
/// <summary>
/// Saves credentials to registry (username/server) and encrypted file (password).
/// </summary>
public static void SaveCredentials(Credentials credentials) {
try {
// Save username and server URL to registry
using (var key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath)) {
key.SetValue(UsernameValueName, credentials.Username);
key.SetValue(ServerUrlValueName, credentials.ServerUrl);
}
// Save encrypted password to file
SaveEncryptedPassword(credentials.Password);
} catch (Exception ex) {
throw new Exception($"Failed to save credentials: {ex.Message}", ex);
}
}
/// <summary>
/// Clears all saved credentials.
/// </summary>
public static void ClearCredentials() {
try {
// Clear registry entries
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true)) {
if (key != null) {
key.DeleteValue(UsernameValueName, false);
key.DeleteValue(ServerUrlValueName, false);
}
}
// Delete password file
if (File.Exists(CredentialFilePath)) { File.Delete(CredentialFilePath); }
} catch (Exception) {
// Ignore errors when clearing
}
}
private static string LoadEncryptedPassword() {
try {
if (!File.Exists(CredentialFilePath)) { return null; }
byte[] encryptedData = File.ReadAllBytes(CredentialFilePath);
byte[] decryptedData = ProtectedData.Unprotect(
encryptedData,
null,
DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(decryptedData);
} catch (Exception) { return null; }
}
private static void SaveEncryptedPassword(string password) {
try {
Directory.CreateDirectory(Path.GetDirectoryName(CredentialFilePath));
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
byte[] encryptedData =
ProtectedData.Protect(passwordBytes, null, DataProtectionScope.CurrentUser);
File.WriteAllBytes(CredentialFilePath, encryptedData);
} catch (Exception ex) {
throw new Exception($"Failed to save encrypted password: {ex.Message}", ex);
}
}
/// <summary>
/// Saves credentials in the same format used by the Unity client for compatibility.
/// </summary>
public static void SaveCredentialsForUnityClient(Credentials credentials) {
try {
// Unity uses PlayerPrefs which on Windows maps to registry entries
// We'll save to the same location the Unity client expects
const string unityRegistryPath =
@"SOFTWARE\Unity\UnityEditor\CompanyName\ProductName";
using (var key = Registry.CurrentUser.CreateSubKey(unityRegistryPath)) {
key.SetValue("urlKey", credentials.ServerUrl);
key.SetValue("nameKey", credentials.Username);
key.SetValue("passwordKey", credentials.Password);
}
} catch (Exception) {
// If Unity credential sharing fails, continue anyway
// The main launcher credentials are still saved
}
}
}
}
@@ -2,10 +2,16 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<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>
</PropertyGroup>
<ItemGroup>
@@ -13,8 +19,6 @@
</ItemGroup>
<ItemGroup>
<None Remove="configuration.txt" />
<Content Include="configuration.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<EmbeddedResource Include="configuration.txt" />
</ItemGroup>
</Project>
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
@@ -9,30 +10,36 @@ using System.Threading.Tasks;
using System.Threading;
namespace EagleInstaller {
internal class UpdaterFailureException : Exception {
public UpdaterFailureException(string message) : base(message) {}
internal class UpdaterFailureException
(string message) : Exception(message);
internal class AuthenticationFailedException
(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 Dictionary<string, string> ConfigFileEntries = ReadConfiguration();
private static readonly Uri ServiceUrl = new Uri(ConfigFileEntries["service_url"]);
private const string CurrentInstallerVersion = "1.0.0";
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 const int DownloadSlots = 4;
private const int RetryCount = 3;
private static readonly string Username = ConfigFileEntries["username"];
private static readonly string Password = ConfigFileEntries["password"];
private static readonly string RemotePrefix = ConfigFileEntries["remote_asset_prefix"];
private readonly HttpClient _httpClient;
private const int DownloadSlots = 8;
private const int RetryCount = 3;
private static Semaphore _pool = new Semaphore(DownloadSlots, DownloadSlots);
private readonly HttpClient _httpClient;
private static readonly SemaphoreSlim Pool = new(DownloadSlots, DownloadSlots);
// Current credentials and service URL
private Credentials CurrentCredentials { get; set; }
private Uri ServiceUrl { get; set; }
private struct FetchAttempt {
public bool Success;
@@ -40,10 +47,11 @@ namespace EagleInstaller {
}
private static Dictionary<string, string> ReadConfiguration() {
string path = Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
@"configuration.txt");
var configText = File.ReadAllText(path);
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>(); }
@@ -59,10 +67,27 @@ namespace EagleInstaller {
return dict;
}
public EagleUpdater()
{
public EagleUpdater(Credentials credentials) {
CurrentCredentials =
credentials ?? throw new ArgumentNullException(nameof(credentials));
ServiceUrl = new Uri(CurrentCredentials.ServerUrl);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization = AuthenticationHeader(Username, Password);
SetAuthentication(CurrentCredentials.Username, CurrentCredentials.Password);
}
/// <summary>
/// Updates the authentication credentials for future requests.
/// </summary>
public void UpdateCredentials(Credentials newCredentials) {
CurrentCredentials = newCredentials;
ServiceUrl = new Uri(CurrentCredentials.ServerUrl);
SetAuthentication(CurrentCredentials.Username, CurrentCredentials.Password);
}
private void SetAuthentication(string username, string password) {
_httpClient.DefaultRequestHeaders.Authorization =
AuthenticationHeader(username, password);
}
private List<string> Discrepancies(
@@ -75,17 +100,53 @@ namespace EagleInstaller {
.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);
Console.WriteLine("Loading old manifest");
Logger.UpdateStatus("Loading old manifest...");
string existingText = await LocalManifestText(existingManifestPath);
Console.WriteLine("Fetching new manifest...");
Logger.UpdateStatus("Fetching new manifest...");
string newText = await RemoteManifestText();
if (existingText != null && existingText.Equals(newText)) {
Console.WriteLine("We already match!");
Logger.UpdateStatus("Files are up to date!");
return true;
}
@@ -95,34 +156,33 @@ namespace EagleInstaller {
List<string> discrepancies = Discrepancies(desiredFiles, currentFiles);
for (int retry = 0; retry < RetryCount; retry++) {
Console.WriteLine($"Attempt {retry + 1}");
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) {
Console.WriteLine(
$"Current state matches manifest. Writing downloaded manifest...");
Logger.UpdateStatus("Update completed successfully!");
Logger.WriteLine("Writing downloaded manifest...");
File.WriteAllText(existingManifestPath, newText);
return true;
} else {
Console.WriteLine("There are still some discrepancies.");
Logger.WriteLine("There are still some discrepancies.");
if (newDiscrepancies.Count == discrepancies.Count) {
Console.WriteLine("We don't seem to be making progress.");
Logger.WriteLine("We don't seem to be making progress.");
} else {
Console.WriteLine(
"We seem to have made some progress. Discrepancies are now:");
newDiscrepancies.ForEach(s => Console.WriteLine($" {s}"));
Logger.WriteLine("Some progress made. Remaining discrepancies:");
newDiscrepancies.ForEach(s => Logger.WriteLine($" {s}"));
discrepancies = newDiscrepancies;
}
Console.WriteLine("Writing out the current state.");
Logger.WriteLine("Writing out the current state.");
File.WriteAllText(existingManifestPath, TextFromShas(newFiles));
currentFiles = newFiles;
}
}
// sadness
Console.WriteLine($"Exhausted {RetryCount} attempts. Giving up.");
Logger.WriteError($"Exhausted {RetryCount} attempts. Update failed.");
return false;
}
@@ -138,7 +198,7 @@ namespace EagleInstaller {
IEnumerable<Task<FetchAttempt>> updateTasks =
pathsToUpdate.Select(remotePathAndSha => {
Console.WriteLine($"Need to update file {remotePathAndSha.Key}");
Logger.WriteLine($"Downloading: {remotePathAndSha.Key}");
return FetchAndWriteOne(
remotePathAndSha.Key,
localPath,
@@ -151,7 +211,7 @@ namespace EagleInstaller {
pathsToDelete.ToList().ForEach(path => {
var localDeletePath = WritePathFromRemotePath(path, localPath);
Console.WriteLine($"Deleting {localDeletePath}...");
Logger.WriteLine($"Deleting: {localDeletePath}");
File.Delete(localDeletePath);
existingFiles.Remove(path);
});
@@ -162,7 +222,7 @@ namespace EagleInstaller {
existingFiles[newItem.Key].Equals(newItem.Value))
select newItem.Key;
pathsOk.ToList().ForEach(path => Console.WriteLine($"Up to date at {path}"));
pathsOk.ToList().ForEach(path => Logger.WriteLine($"Up to date: {path}"));
var fetchAttemptResults = await Task.WhenAll(updateTasks);
@@ -173,11 +233,11 @@ namespace EagleInstaller {
.ForEach(fa => existingFiles[fa.Key] = fa.Value);
if (fetchAttemptResults.ToList().TrueForAll(fa => fa.Success)) {
Console.WriteLine("All download tasks succeeded.");
Logger.WriteLine("All downloads completed successfully.");
} else {
Console.WriteLine("Failed to fetch all files!");
Logger.WriteError("Some file downloads failed!");
}
} catch (Exception e) { Console.WriteLine($"Caught unexpected exception {e}"); }
} catch (Exception e) { Logger.WriteError($"Unexpected exception: {e}"); }
return existingFiles;
}
@@ -199,6 +259,13 @@ namespace EagleInstaller {
async Task<string> RemoteManifestText() {
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, RemoteManifestName));
if (response.StatusCode == HttpStatusCode.Unauthorized) {
throw new AuthenticationFailedException(
"Authentication failed while fetching manifest. Invalid username or password.");
}
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
using StreamReader reader = new StreamReader(responseStream);
return await reader.ReadToEndAsync();
@@ -234,26 +301,31 @@ namespace EagleInstaller {
Directory.CreateDirectory(Path.GetDirectoryName(writePath));
_pool.WaitOne();
await Pool.WaitAsync();
try {
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, RemotePrefix + remotePath));
if (response.StatusCode == HttpStatusCode.Unauthorized) {
throw new AuthenticationFailedException(
$"Authentication failed while downloading {remotePath}. Invalid username or password.");
}
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = File.Create(writePath);
using MemoryStream memStream = new MemoryStream();
await responseStream.CopyToAsync(memStream);
memStream.Position = 0;
_pool.Release();
var sha = System.Security.Cryptography.SHA256.Create();
string computedSha = BitConverter.ToString(sha.ComputeHash(memStream))
.Replace("-", string.Empty)
.ToLower();
if (computedSha.Equals(expectedSha)) {
Console.WriteLine($"Verified SHA256 for {remotePath}; writing...");
Logger.WriteLine($"Verified and saved: {remotePath}");
memStream.Position = 0;
await memStream.CopyToAsync(fileStream);
return new FetchAttempt {
@@ -261,18 +333,67 @@ namespace EagleInstaller {
Kvp = new KeyValuePair<String, String>(remotePath, computedSha)
};
} else {
Console.WriteLine($"SHAs do not match for {remotePath}");
Logger.WriteError($"SHA mismatch for {remotePath}");
return new FetchAttempt() { Success = false };
}
} catch (TaskCanceledException e) {
Console.WriteLine($"Caught ObjectDisposedException {e}");
Logger.WriteError($"Download cancelled: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (HttpRequestException e) {
Console.WriteLine($"Caught HttpRequestException {e}");
Logger.WriteError($"Network error: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (Exception e) {
Console.WriteLine($"Caught Exception {e}");
Logger.WriteError($"Download failed: {e.Message}");
return new FetchAttempt() { Success = false };
} finally { Pool.Release(); }
}
public async Task<bool> DownloadAndLaunchNewInstaller(string installerUrl) {
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
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, installerUrl));
if (response.StatusCode == HttpStatusCode.Unauthorized) {
throw new AuthenticationFailedException(
"Authentication failed while downloading new installer. Invalid username or password.");
}
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = File.Create(newInstallerPath);
await responseStream.CopyToAsync(fileStream);
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.UpdateStatus("Launching new installer...");
System.Diagnostics.Process.Start(processInfo);
return true;
} catch (Exception e) {
Logger.WriteError($"Failed to download or launch new installer: {e}");
return false;
}
}
}
@@ -0,0 +1,29 @@
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}"); }
}
}
@@ -0,0 +1,212 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace EagleInstaller {
public partial class LoginDialog : Form {
public Credentials Credentials { get; private set; }
public bool SaveCredentials { get; private set; }
private TextBox usernameTextBox;
private TextBox passwordTextBox;
private TextBox serverUrlTextBox;
private CheckBox saveCredentialsCheckBox;
private Button okButton;
private Button cancelButton;
private Label titleLabel;
private Label usernameLabel;
private Label passwordLabel;
private Label serverLabel;
public LoginDialog(string title = "Eagle0 Login", Credentials existingCredentials = null) {
InitializeComponent();
this.Text = title;
titleLabel.Text = title;
if (existingCredentials != null) {
usernameTextBox.Text = existingCredentials.Username ?? "";
passwordTextBox.Text = existingCredentials.Password ?? "";
serverUrlTextBox.Text = existingCredentials.ServerUrl ?? "https://eagle0.net";
} else {
serverUrlTextBox.Text = "https://eagle0.net";
}
// Default to saving credentials
saveCredentialsCheckBox.Checked = true;
}
private void InitializeComponent() {
// Form properties
this.Size = new Size(400, 280);
this.StartPosition = FormStartPosition.CenterScreen;
this.FormBorderStyle = FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.ShowInTaskbar = false;
// Title label
titleLabel = new Label {
Text = "Eagle0 Login",
Font = new Font("Microsoft Sans Serif", 12F, FontStyle.Bold),
Location = new Point(20, 20),
Size = new Size(350, 25),
TextAlign = ContentAlignment.MiddleCenter
};
// Username label and textbox
usernameLabel = new Label {
Text = "Username:",
Location = new Point(20, 60),
Size = new Size(80, 23),
TextAlign = ContentAlignment.MiddleLeft
};
usernameTextBox = new TextBox {
Location = new Point(110, 60),
Size = new Size(250, 23),
TabIndex = 0
};
// Password label and textbox
passwordLabel = new Label {
Text = "Password:",
Location = new Point(20, 90),
Size = new Size(80, 23),
TextAlign = ContentAlignment.MiddleLeft
};
passwordTextBox = new TextBox {
Location = new Point(110, 90),
Size = new Size(250, 23),
UseSystemPasswordChar = true,
TabIndex = 1
};
// Server URL label and textbox
serverLabel = new Label {
Text = "Server:",
Location = new Point(20, 120),
Size = new Size(80, 23),
TextAlign = ContentAlignment.MiddleLeft
};
serverUrlTextBox = new TextBox {
Location = new Point(110, 120),
Size = new Size(250, 23),
TabIndex = 2
};
// Save credentials checkbox
saveCredentialsCheckBox = new CheckBox {
Text = "Remember credentials",
Location = new Point(20, 160),
Size = new Size(200, 23),
TabIndex = 3
};
// OK button
okButton = new Button {
Text = "OK",
Location = new Point(200, 200),
Size = new Size(80, 30),
TabIndex = 4,
DialogResult = DialogResult.OK
};
okButton.Click += OkButton_Click;
// Cancel button
cancelButton = new Button {
Text = "Cancel",
Location = new Point(290, 200),
Size = new Size(80, 30),
TabIndex = 5,
DialogResult = DialogResult.Cancel
};
// Add controls to form
this.Controls.AddRange(new Control[] {
titleLabel,
usernameLabel,
usernameTextBox,
passwordLabel,
passwordTextBox,
serverLabel,
serverUrlTextBox,
saveCredentialsCheckBox,
okButton,
cancelButton
});
// Set default button and cancel button
this.AcceptButton = okButton;
this.CancelButton = cancelButton;
// Focus on username field
this.ActiveControl = usernameTextBox;
}
private void OkButton_Click(object sender, EventArgs e) {
// Validate input
if (string.IsNullOrWhiteSpace(usernameTextBox.Text)) {
MessageBox.Show(
"Username is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
usernameTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(passwordTextBox.Text)) {
MessageBox.Show(
"Password is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
passwordTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(serverUrlTextBox.Text)) {
MessageBox.Show(
"Server URL is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
serverUrlTextBox.Focus();
return;
}
// Create credentials object
Credentials = new Credentials {
Username = usernameTextBox.Text.Trim(),
Password = passwordTextBox.Text,
ServerUrl = serverUrlTextBox.Text.Trim()
};
// Normalize server URL
if (!Credentials.ServerUrl.StartsWith("http://") &&
!Credentials.ServerUrl.StartsWith("https://")) {
Credentials.ServerUrl = "https://" + Credentials.ServerUrl;
}
SaveCredentials = saveCredentialsCheckBox.Checked;
this.DialogResult = DialogResult.OK;
}
/// <summary>
/// Shows the login dialog and returns the entered credentials, or null if cancelled.
/// </summary>
public static (Credentials credentials, bool saveCredentials) ShowLoginDialog(
string title = "Eagle0 Login",
Credentials existingCredentials = null) {
using (var dialog = new LoginDialog(title, existingCredentials)) {
if (dialog.ShowDialog() == DialogResult.OK) {
return (dialog.Credentials, dialog.SaveCredentials);
}
return (null, false);
}
}
}
}
@@ -0,0 +1,314 @@
using System;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace EagleInstaller {
public partial class MainForm : Form {
private Panel loginPanel;
private Panel progressPanel;
private TextBox progressTextBox;
private Label statusLabel;
private Button changeCredentialsButton;
private Button exitButton;
// Login controls
private TextBox usernameTextBox;
private TextBox passwordTextBox;
private TextBox serverUrlTextBox;
private CheckBox saveCredentialsCheckBox;
private Button loginButton;
private Button cancelButton;
public Credentials EnteredCredentials { get; private set; }
public bool SaveCredentials { get; private set; }
public bool LoginCancelled { get; private set; }
public MainForm() {
InitializeComponent();
ShowLoginView();
}
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 login panel
CreateLoginPanel();
// Create progress panel
CreateProgressPanel();
// Add panels to form
this.Controls.Add(loginPanel);
this.Controls.Add(progressPanel);
}
private void CreateLoginPanel() {
loginPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(20) };
// Title
var titleLabel = new Label {
Text = "Eagle0 Login",
Font = new Font("Microsoft Sans Serif", 14F, FontStyle.Bold),
Location = new Point(20, 20),
Size = new Size(540, 30),
TextAlign = ContentAlignment.MiddleCenter
};
// Username
var usernameLabel = new Label {
Text = "Username:",
Location = new Point(50, 80),
Size = new Size(100, 23)
};
usernameTextBox = new TextBox {
Location = new Point(160, 80),
Size = new Size(300, 23),
TabIndex = 0
};
// Password
var passwordLabel = new Label {
Text = "Password:",
Location = new Point(50, 110),
Size = new Size(100, 23)
};
passwordTextBox = new TextBox {
Location = new Point(160, 110),
Size = new Size(300, 23),
UseSystemPasswordChar = true,
TabIndex = 1
};
// Server
var serverLabel = new Label {
Text = "Server:",
Location = new Point(50, 140),
Size = new Size(100, 23)
};
serverUrlTextBox = new TextBox {
Location = new Point(160, 140),
Size = new Size(300, 23),
TabIndex = 2
};
// Save credentials checkbox
saveCredentialsCheckBox = new CheckBox {
Text = "Remember credentials",
Location = new Point(50, 180),
Size = new Size(200, 23),
Checked = true,
TabIndex = 3
};
// Buttons
loginButton = new Button {
Text = "Login",
Location = new Point(280, 220),
Size = new Size(80, 30),
TabIndex = 4
};
loginButton.Click += LoginButton_Click;
cancelButton = new Button {
Text = "Cancel",
Location = new Point(380, 220),
Size = new Size(80, 30),
TabIndex = 5
};
cancelButton.Click += CancelButton_Click;
// Add controls to login panel
loginPanel.Controls.AddRange(new Control[] {
titleLabel,
usernameLabel,
usernameTextBox,
passwordLabel,
passwordTextBox,
serverLabel,
serverUrlTextBox,
saveCredentialsCheckBox,
loginButton,
cancelButton
});
// Set form defaults
this.AcceptButton = loginButton;
this.CancelButton = cancelButton;
}
private void CreateProgressPanel() {
progressPanel =
new Panel { Dock = DockStyle.Fill, Padding = new Padding(10), Visible = false };
// 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, 280),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
Font = new Font("Consolas", 9F),
BackColor = Color.Black,
ForeColor = Color.LightGreen,
TabIndex = 0
};
// Control buttons
changeCredentialsButton = new Button {
Text = "Change Credentials",
Location = new Point(350, 370),
Size = new Size(130, 30),
Enabled = false
};
changeCredentialsButton.Click += ChangeCredentialsButton_Click;
exitButton = new Button {
Text = "Exit",
Location = new Point(490, 370),
Size = new Size(80, 30)
};
exitButton.Click += ExitButton_Click;
// Add controls to progress panel
progressPanel.Controls.AddRange(new Control[] {
titleLabel,
statusLabel,
progressTextBox,
changeCredentialsButton,
exitButton
});
}
public void ShowLoginView() {
loginPanel.Visible = true;
progressPanel.Visible = false;
this.ActiveControl = usernameTextBox;
}
public void ShowProgressView() {
loginPanel.Visible = false;
progressPanel.Visible = true;
changeCredentialsButton.Enabled = true;
}
public void SetLoginCredentials(Credentials credentials) {
if (credentials != null) {
usernameTextBox.Text = credentials.Username ?? "";
passwordTextBox.Text = credentials.Password ?? "";
serverUrlTextBox.Text = credentials.ServerUrl ?? "https://eagle0.net";
} else {
serverUrlTextBox.Text = "https://eagle0.net";
}
}
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 LoginButton_Click(object sender, EventArgs e) {
// Validate input
if (string.IsNullOrWhiteSpace(usernameTextBox.Text)) {
MessageBox.Show(
"Username is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
usernameTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(passwordTextBox.Text)) {
MessageBox.Show(
"Password is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
passwordTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(serverUrlTextBox.Text)) {
MessageBox.Show(
"Server URL is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
serverUrlTextBox.Focus();
return;
}
// Create credentials
var serverUrl = serverUrlTextBox.Text.Trim();
if (!serverUrl.StartsWith("http://") && !serverUrl.StartsWith("https://")) {
serverUrl = "https://" + serverUrl;
}
EnteredCredentials = new Credentials {
Username = usernameTextBox.Text.Trim(),
Password = passwordTextBox.Text,
ServerUrl = serverUrl
};
SaveCredentials = saveCredentialsCheckBox.Checked;
LoginCancelled = false;
ShowProgressView();
}
private void CancelButton_Click(object sender, EventArgs e) {
LoginCancelled = true;
this.Close();
}
private void ChangeCredentialsButton_Click(object sender, EventArgs e) { ShowLoginView(); }
private void ExitButton_Click(object sender, EventArgs e) { this.Close(); }
public static MainForm CreateMainForm(
string title = "Eagle0 Login",
Credentials existingCredentials = null) {
var form = new MainForm();
form.Text = title;
form.SetLoginCredentials(existingCredentials);
return form;
}
}
}
@@ -2,6 +2,7 @@
using System.IO;
using System.Threading.Tasks;
using System.Threading;
using System.Windows.Forms;
namespace EagleInstaller {
class Program {
@@ -11,17 +12,278 @@ namespace EagleInstaller {
private static readonly string ExeLocation = Path.Combine(FileLocation, "eagle0.exe");
static async Task Main(string[] args) {
EagleUpdater updater = new EagleUpdater();
// Enable visual styles for Windows Forms
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
if (success) {
Console.WriteLine("Launching application");
System.Diagnostics.Process.Start(ExeLocation);
} else {
Console.WriteLine("Failed to update!");
Thread.Sleep(int.MaxValue);
// Handle installer replacement if launched with --replace-installer flag
if (args.Length > 0 && args[0] == "--replace-installer") {
await HandleInstallerReplacement(args);
return;
}
// Check for credential management flags
bool forceLogin =
args.Length > 0 && (args[0] == "--login" || args[0] == "--change-credentials");
bool clearCredentials = args.Length > 0 && args[0] == "--clear-credentials";
bool showHelp = args.Length > 0 && (args[0] == "--help" || args[0] == "-h");
// Show help if requested
if (showHelp) {
ShowHelp();
return;
}
// Handle clear credentials flag
if (clearCredentials) {
Console.WriteLine("Clearing saved credentials...");
CredentialManager.ClearCredentials();
Console.WriteLine("Credentials cleared successfully.");
return;
}
// Create main form
var mainForm = MainForm.CreateMainForm();
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, forceLogin);
// 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, bool forceLogin) {
try {
// Get credentials (load saved or prompt user)
Credentials credentials = await GetValidCredentials(mainForm, forceLogin);
if (credentials == null) {
Logger.WriteError("No valid credentials provided. Exiting.");
return;
}
// Switch to progress view
SafeInvoke(mainForm, () => mainForm.ShowProgressView());
EagleUpdater updater = new EagleUpdater(credentials);
bool credentialsAreValid = false;
while (!credentialsAreValid) {
try {
// 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);
if (downloadSuccess) {
Logger.UpdateStatus(
"New installer launched. Exiting current version.");
return; // Exit this version
} else {
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) {
Logger.UpdateStatus("Launching Eagle0...");
System.Diagnostics.Process.Start(ExeLocation);
credentialsAreValid = true;
} else {
Logger.WriteError("Update failed!");
credentialsAreValid = true; // Don't retry on non-auth failures
}
} catch (AuthenticationFailedException ex) {
Logger.WriteError($"Authentication failed: {ex.Message}");
// Clear saved credentials since they're invalid
CredentialManager.ClearCredentials();
// Show login view again
SafeInvoke(mainForm, () => mainForm.ShowLoginView());
// Wait for new credentials
credentials = await GetValidCredentials(
mainForm,
true,
"Authentication Failed - Please re-enter credentials");
if (credentials == null) {
Logger.WriteError("No valid credentials provided. Exiting.");
return;
}
// Switch back to progress view
SafeInvoke(mainForm, () => mainForm.ShowProgressView());
// Update updater with new credentials
updater.UpdateCredentials(credentials);
}
}
} catch (Exception ex) { Logger.WriteError($"Unexpected error: {ex.Message}"); }
}
private static async Task HandleInstallerReplacement(string[] args) {
if (args.Length < 2) {
Console.WriteLine("Error: --replace-installer requires path to old installer");
return;
}
string oldInstallerPath = args[1];
string currentInstallerPath =
Environment.ProcessPath ?? System.AppContext.BaseDirectory;
Console.WriteLine($"Replacing installer: {oldInstallerPath}");
try {
// Wait a moment for the old process to exit
await Task.Delay(2000);
// Backup old installer
string backupPath = oldInstallerPath + ".backup";
if (File.Exists(oldInstallerPath)) { File.Move(oldInstallerPath, backupPath); }
// Copy new installer to old location
File.Copy(currentInstallerPath, oldInstallerPath, true);
Console.WriteLine("Installer replaced successfully. Launching new version...");
// Launch the newly installed version
var processInfo = new System.Diagnostics.ProcessStartInfo {
FileName = oldInstallerPath,
UseShellExecute = true
};
System.Diagnostics.Process.Start(processInfo);
// Clean up backup after successful replacement
if (File.Exists(backupPath)) { File.Delete(backupPath); }
} catch (Exception e) {
Console.WriteLine($"Failed to replace installer: {e.Message}");
// Restore backup if replacement failed
string backupPath = oldInstallerPath + ".backup";
if (File.Exists(backupPath) && !File.Exists(oldInstallerPath)) {
File.Move(backupPath, oldInstallerPath);
}
}
}
private static async Task<Credentials> GetValidCredentials(
MainForm mainForm,
bool forceLogin = false,
string title = "Eagle0 Login") {
Credentials savedCredentials = null;
// Try to load saved credentials first (unless forced to login)
if (!forceLogin) {
savedCredentials = CredentialManager.LoadCredentials();
if (savedCredentials != null) {
Logger.WriteLine(
$"Found saved credentials for user: {savedCredentials.Username}");
// Pre-fill the form with saved credentials but let user click Login
SafeInvoke(mainForm, () => mainForm.SetLoginCredentials(savedCredentials));
}
}
// If no saved credentials or force login, show login form
if (savedCredentials == null || forceLogin) {
if (savedCredentials == null) {
Logger.WriteLine(
"No saved credentials found. Please enter your login information.");
} else {
Logger.WriteLine("Please enter new credentials.");
}
// Set the form title
SafeInvoke(mainForm, () => mainForm.Text = title);
}
// Wait for user to enter credentials through the form
return await WaitForCredentials(mainForm);
}
private static async Task<Credentials> WaitForCredentials(MainForm mainForm) {
var tcs = new TaskCompletionSource<Credentials>();
// Set up event handler to capture when login is completed
EventHandler loginCompleted = null;
loginCompleted = (sender, e) => {
var form = sender as MainForm;
if (form != null && form.EnteredCredentials != null) {
var credentials = form.EnteredCredentials;
var saveCredentials = form.SaveCredentials;
if (saveCredentials) {
try {
CredentialManager.SaveCredentials(credentials);
CredentialManager.SaveCredentialsForUnityClient(credentials);
Logger.WriteLine("Credentials saved successfully.");
} catch (Exception ex) {
Logger.WriteError($"Failed to save credentials: {ex.Message}");
}
}
tcs.SetResult(credentials);
} else if (form != null && form.LoginCancelled) {
tcs.SetResult(null);
}
};
// Monitor for form changes (this is a simplified approach)
var monitorTask = Task.Run(async () => {
while (!tcs.Task.IsCompleted) {
await Task.Delay(100);
if (mainForm.EnteredCredentials != null || mainForm.LoginCancelled) {
loginCompleted(mainForm, EventArgs.Empty);
break;
}
}
});
return await tcs.Task;
}
private static void SafeInvoke(Control control, Action action) {
if (control.IsHandleCreated && control.InvokeRequired) {
control.Invoke(action);
} else if (control.IsHandleCreated) {
action();
}
}
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(" --login, --change-credentials Force credential input dialog");
Console.WriteLine(" --clear-credentials Clear saved credentials and exit");
Console.WriteLine();
Console.WriteLine("NORMAL OPERATION:");
Console.WriteLine(" Run without arguments to start the normal update process.");
Console.WriteLine(
" If credentials are saved, you'll see a 3-second prompt to change them.");
Console.WriteLine(" Press 'C' during this prompt to change credentials.");
Console.WriteLine();
Console.WriteLine("EXAMPLES:");
Console.WriteLine(" EagleInstaller.exe # Normal operation");
Console.WriteLine(" EagleInstaller.exe --login # Force new login");
Console.WriteLine(" EagleInstaller.exe --clear-credentials # Clear saved login");
}
}
}
@@ -1,6 +1,3 @@
service_url = https://eagle0.net
username = client
password = bWhsDKH-LPUJ4LwdgDJY
manifest_name = eagle0WIN_manifest.txt
remote_manifest_prefix = assets/unity3d/
remote_asset_prefix = assets/unity3d/win/
@@ -0,0 +1,129 @@
# 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.
@@ -0,0 +1,6 @@
# 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
@@ -0,0 +1,15 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "installer_build_handler_lib",
srcs = ["main.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/build/installer_build_handler",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/aws"],
)
go_binary(
name = "installer_build_handler",
embed = [":installer_build_handler_lib"],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,103 @@
package main
import (
"crypto/sha256"
"fmt"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"io"
"log"
"os"
"path/filepath"
)
var bucketName = "eagle0-windows"
var installerKey = "installer/EagleInstaller.exe"
var versionKey = "installer/version.txt"
func sha(filePath string) (string, error) {
fileToCheck, err := os.Open(filePath)
if err != nil {
return "", err
}
defer fileToCheck.Close()
h := sha256.New()
if _, err := io.Copy(h, fileToCheck); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func main() {
if len(os.Args) != 2 {
log.Fatal("Usage: installer_build_handler <path-to-EagleInstaller.exe>")
}
installerPath := os.Args[1]
// Verify the file exists
if _, err := os.Stat(installerPath); os.IsNotExist(err) {
log.Fatalf("Installer file does not exist: %s", installerPath)
}
// Calculate SHA256 of the installer
installerSha, err := sha(installerPath)
if err != nil {
log.Fatalf("Failed to calculate SHA256: %v", err)
}
log.Printf("Uploading installer with SHA256: %s", installerSha)
// Create BucketBasics instance
bucketBasics, err := aws.NewBucketBasics()
if err != nil {
log.Fatalf("Failed to create AWS client: %v", err)
}
// Upload installer to S3
err = bucketBasics.UploadFile(bucketName, installerKey, installerPath)
if err != nil {
log.Fatalf("Failed to upload installer: %v", err)
}
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)
// Write version info to temp file
tempVersionFile := filepath.Join(os.TempDir(), "installer_version.txt")
err = os.WriteFile(tempVersionFile, []byte(versionContent), 0644)
if err != nil {
log.Fatalf("Failed to create version file: %v", err)
}
// Upload version file to S3
err = bucketBasics.UploadFile(bucketName, versionKey, tempVersionFile)
if err != nil {
log.Fatalf("Failed to upload version file: %v", err)
}
log.Printf("Successfully uploaded version info to s3://%s/%s", bucketName, versionKey)
// Create installer manifest section
installerManifest := fmt.Sprintf("installer_version=1.0.0\ninstaller_url=%s\ninstaller_sha256=%s", installerKey, installerSha)
// Write installer manifest to temp file for manifest manager
tempManifestFile := filepath.Join(os.TempDir(), "installer_manifest.txt")
err = os.WriteFile(tempManifestFile, []byte(installerManifest), 0644)
if err != nil {
log.Fatalf("Failed to create installer manifest file: %v", err)
}
log.Println("Updating unified manifest...")
// Note: In the GitHub workflow, this will be called via bazel run
// bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer tempManifestFile
// Clean up temp files
os.Remove(tempVersionFile)
os.Remove(tempManifestFile)
log.Println("Installer deployment completed successfully!")
}
@@ -0,0 +1,15 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "manifest_manager_lib",
srcs = ["main.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/build/manifest_manager",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/aws"],
)
go_binary(
name = "manifest_manager",
embed = [":manifest_manager_lib"],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,148 @@
package main
import (
"fmt"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"log"
"os"
"strings"
"time"
)
const bucketName = "eagle0-windows"
const manifestPath = "eagle0_manifest.txt"
type ManifestManager struct {
bucketBasics aws.BucketBasics
}
type ManifestSection struct {
Name string // "unity3d" or "installer"
Content string
}
func NewManifestManager() (*ManifestManager, error) {
bb, err := aws.NewBucketBasics()
if err != nil {
return nil, err
}
return &ManifestManager{bucketBasics: bb}, nil
}
func (mm *ManifestManager) parseManifest(content string) map[string]string {
sections := make(map[string]string)
lines := strings.Split(content, "\n")
var currentSection string
var sectionLines []string
for _, line := range lines {
if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, "_section_start") {
// Save previous section if exists
if currentSection != "" {
sections[currentSection] = strings.Join(sectionLines, "\n")
}
// Start new section
currentSection = strings.TrimSuffix(strings.TrimPrefix(line, "# "), "_section_start")
sectionLines = []string{}
} else if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, "_section_end") {
// End current section
if currentSection != "" {
sections[currentSection] = strings.Join(sectionLines, "\n")
currentSection = ""
sectionLines = []string{}
}
} else if currentSection != "" {
sectionLines = append(sectionLines, line)
}
}
return sections
}
func (mm *ManifestManager) buildManifest(sections map[string]string) string {
var manifest strings.Builder
manifest.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format(time.RFC3339)))
manifest.WriteString("# Eagle0 Unified Manifest\n")
// Installer metadata as comments (for client compatibility)
if installer, exists := sections["installer"]; exists {
lines := strings.Split(strings.TrimSpace(installer), "\n")
for _, line := range lines {
if line != "" {
manifest.WriteString(fmt.Sprintf("# %s\n", line))
}
}
}
manifest.WriteString("\n")
// Unity3D section (file hashes)
if unity, exists := sections["unity3d"]; exists {
manifest.WriteString(unity)
if !strings.HasSuffix(unity, "\n") {
manifest.WriteString("\n")
}
}
return manifest.String()
}
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) error {
log.Printf("Updating manifest section: %s", sectionName)
// Fetch existing manifest
existing, err := mm.bucketBasics.FetchString(bucketName, manifestPath)
if err != nil {
log.Printf("No existing manifest found, creating new one")
existing = ""
}
// Parse sections
sections := mm.parseManifest(existing)
// Update the specified section
sections[sectionName] = sectionContent
// Build new manifest
newManifest := mm.buildManifest(sections)
// Upload updated manifest
err = mm.bucketBasics.UploadBytes(bucketName, manifestPath, []byte(newManifest))
if err != nil {
return fmt.Errorf("failed to upload manifest: %v", err)
}
log.Printf("Successfully updated manifest section: %s", sectionName)
return nil
}
func main() {
if len(os.Args) != 3 {
log.Fatal("Usage: manifest_manager <section_name> <section_content_file>")
}
sectionName := os.Args[1]
contentFile := os.Args[2]
// Read section content from file
content, err := os.ReadFile(contentFile)
if err != nil {
log.Fatalf("Failed to read content file: %v", err)
}
// Create manifest manager
mm, err := NewManifestManager()
if err != nil {
log.Fatalf("Failed to create manifest manager: %v", err)
}
// Update the section
err = mm.UpdateSection(sectionName, string(content))
if err != nil {
log.Fatalf("Failed to update manifest: %v", err)
}
log.Println("Manifest update completed successfully!")
}
@@ -3,15 +3,29 @@ package main
import (
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"net/http"
"strings"
"time"
)
var bucketName = "eagle0-windows"
var destinationRoot = "unity3d/"
func getAsset(w http.ResponseWriter, r *http.Request) {
// Get the file name from the URL
fileName := r.URL.Path[len("/unity3d/"):]
urlPath := r.URL.Path
var bucketName string
var destinationRoot string
var fileName string
// Check if the URL path starts with "/unity3d/"
if strings.HasPrefix(urlPath, "/unity3d/") {
bucketName = "eagle0-windows"
destinationRoot = "unity3d/"
fileName = urlPath[len("/unity3d/"):]
} else if strings.HasPrefix(urlPath, "/headshots/") {
bucketName = "eagle0-headshots"
destinationRoot = ""
fileName = urlPath[len("/headshots/"):]
} else {
// If the URL path does not start with either, return an error
http.Error(w, "Invalid URL path", http.StatusBadRequest)
}
// Create a presigned URL for the file
url, err := aws.GetPresignedURL(bucketName, destinationRoot+fileName, 10*time.Minute)
@@ -25,7 +39,7 @@ func getAsset(w http.ResponseWriter, r *http.Request) {
}
func main() {
http.HandleFunc("GET /unity3d/", getAsset)
http.HandleFunc("GET /", getAsset)
err := http.ListenAndServe(":3333", nil)
if err != nil {

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