Compare 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
188 changed files with 24012 additions and 1198 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 -1
View File
@@ -6,4 +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
*.tsv 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" />
@@ -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() {
@@ -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++;
}
@@ -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(); }
}
}
@@ -118,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);
}
}
@@ -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:
@@ -290,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);
}
@@ -308,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");
}
}
@@ -323,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");
}
}
@@ -337,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}}";
}
}
@@ -353,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) {
@@ -364,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;
}
@@ -383,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
@@ -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 {
@@ -0,0 +1,15 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_binary(
name = "headshots",
embed = [":headshots_lib"],
visibility = ["//visibility:public"],
)
go_library(
name = "headshots_lib",
srcs = ["main.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/util/hero_generation/headshots",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/tsv"],
)
@@ -0,0 +1,338 @@
package main
import (
"fmt"
"hash/fnv"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/tsv"
)
// Hero represents a hero from the TSV data
type Hero struct {
Name string
Strength int
Agility int
Constitution int
Wisdom int
Charisma int
Sum int
Vigor int
Integrity int
Ambition int
Gregariousness int
Bravery int
Profession string
GreatPerson bool
ImagePath string
Gender string
Backstory string
Personality string
}
func main() {
// Check for command line arguments
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "Usage: %s <input-file> <output-file>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Example: %s input.herodata output.herodata\n", os.Args[0])
os.Exit(1)
}
inputPath := os.Args[1]
outputPath := os.Args[2]
fmt.Printf("Reading hero data from: %s\n", inputPath)
fmt.Printf("Writing modified data to: %s\n", outputPath)
// Load the TSV data
lines, err := tsv.LoadLines(inputPath)
if err != nil {
log.Fatalf("Error loading TSV file: %v", err)
}
if len(lines) == 0 {
log.Fatal("No data found in file")
}
fmt.Printf("Loaded %d lines of data\n", len(lines))
// Find the image_path field index in the header
imagePathIndex := -1
nameIndex := -1
if len(lines) > 0 {
for i, field := range lines[0] {
if field == "image_path" {
imagePathIndex = i
}
if field == "name" {
nameIndex = i
}
}
}
if imagePathIndex == -1 {
log.Fatal("Could not find 'image_path' field in header")
}
if nameIndex == -1 {
log.Fatal("Could not find 'name' field in header")
}
fmt.Printf("Found image_path field at index %d\n", imagePathIndex)
fmt.Printf("Found name field at index %d\n", nameIndex)
// Generate timestamp for this run
timestamp := time.Now().Unix()
// Process lines and replace image paths
outputLines := make([][]string, len(lines))
// Copy header as-is
outputLines[0] = lines[0]
for i, line := range lines[1:] { // Skip header
lineNum := i + 1 // Adjust for header skip
if len(line) <= imagePathIndex || len(line) <= nameIndex {
log.Printf("Warning: line %d has insufficient fields (%d), copying as-is", lineNum+1, len(line))
outputLines[lineNum] = line
continue
}
// Copy the line
newLine := make([]string, len(line))
copy(newLine, line)
// Replace the image path with generated placeholder
heroName := line[nameIndex]
newImagePath := generateImagePath(timestamp, lineNum, heroName)
newLine[imagePathIndex] = newImagePath
outputLines[lineNum] = newLine
}
// Write output file
err = writeTSVFile(outputPath, outputLines)
if err != nil {
log.Fatalf("Error writing output file: %v", err)
}
fmt.Printf("Successfully wrote %d lines to %s\n", len(outputLines), outputPath)
}
func generateImagePath(timestamp int64, index int, heroName string) string {
// Create FNV-1a 64-bit hash of the hero name
h := fnv.New64a()
h.Write([]byte(heroName))
hash := h.Sum64()
// Convert to hex string
hashHex := fmt.Sprintf("%016x", hash)
// Generate path: "generated/{TIMESTAMP}_{INDEX}_{HASH}.png"
return fmt.Sprintf("generated/%d_%d_%s.png", timestamp, index, hashHex)
}
func writeTSVFile(filename string, lines [][]string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
for _, line := range lines {
// Join fields with tabs and write line
output := strings.Join(line, "\t") + "\n"
_, err := file.WriteString(output)
if err != nil {
return err
}
}
return nil
}
// FieldIndices holds the column indices for each field
type FieldIndices struct {
Name int
Strength int
Agility int
Constitution int
Wisdom int
Charisma int
Sum int
Vigor int
Integrity int
Ambition int
Gregariousness int
Bravery int
Profession int
GreatPerson int
ImagePath int
Gender int
Backstory int
Personality int
}
// findFieldIndices parses the header row to find the index of each field
func findFieldIndices(header []string) (*FieldIndices, error) {
indices := &FieldIndices{
Name: -1,
Strength: -1,
Agility: -1,
Constitution: -1,
Wisdom: -1,
Charisma: -1,
Sum: -1,
Vigor: -1,
Integrity: -1,
Ambition: -1,
Gregariousness: -1,
Bravery: -1,
Profession: -1,
GreatPerson: -1,
ImagePath: -1,
Gender: -1,
Backstory: -1,
Personality: -1,
}
fieldMap := map[string]*int{
"name": &indices.Name,
"strength": &indices.Strength,
"agility": &indices.Agility,
"constitution": &indices.Constitution,
"wisdom": &indices.Wisdom,
"charisma": &indices.Charisma,
"sum": &indices.Sum,
"vigor": &indices.Vigor,
"integrity": &indices.Integrity,
"ambition": &indices.Ambition,
"gregariousness": &indices.Gregariousness,
"bravery": &indices.Bravery,
"profession": &indices.Profession,
"great person": &indices.GreatPerson,
"image_path": &indices.ImagePath,
"gender": &indices.Gender,
"backstory": &indices.Backstory,
"personality": &indices.Personality,
}
for i, field := range header {
if ptr, exists := fieldMap[strings.ToLower(field)]; exists {
*ptr = i
}
}
// Check that all required fields were found
requiredFields := []string{"name", "strength", "agility", "constitution", "wisdom", "charisma", "sum", "vigor", "integrity", "ambition", "gregariousness", "bravery", "profession", "great person", "image_path", "gender", "backstory", "personality"}
for _, field := range requiredFields {
if ptr, exists := fieldMap[field]; exists && *ptr == -1 {
return nil, fmt.Errorf("required field '%s' not found in header", field)
}
}
return indices, nil
}
func parseHeroLine(fields []string, indices *FieldIndices) (*Hero, error) {
maxIndex := 0
indexValues := []int{indices.Name, indices.Strength, indices.Agility, indices.Constitution, indices.Wisdom, indices.Charisma, indices.Sum, indices.Vigor, indices.Integrity, indices.Ambition, indices.Gregariousness, indices.Bravery, indices.Profession, indices.GreatPerson, indices.ImagePath, indices.Gender, indices.Backstory, indices.Personality}
for _, idx := range indexValues {
if idx > maxIndex {
maxIndex = idx
}
}
if len(fields) <= maxIndex {
return nil, fmt.Errorf("insufficient fields: expected at least %d, got %d", maxIndex+1, len(fields))
}
hero := &Hero{
Name: fields[indices.Name],
Profession: fields[indices.Profession],
ImagePath: fields[indices.ImagePath],
Gender: fields[indices.Gender],
Backstory: fields[indices.Backstory],
Personality: fields[indices.Personality],
}
// Parse numeric fields
var err error
if hero.Strength, err = strconv.Atoi(fields[indices.Strength]); err != nil {
return nil, fmt.Errorf("invalid strength: %v", err)
}
if hero.Agility, err = strconv.Atoi(fields[indices.Agility]); err != nil {
return nil, fmt.Errorf("invalid agility: %v", err)
}
if hero.Constitution, err = strconv.Atoi(fields[indices.Constitution]); err != nil {
return nil, fmt.Errorf("invalid constitution: %v", err)
}
if hero.Wisdom, err = strconv.Atoi(fields[indices.Wisdom]); err != nil {
return nil, fmt.Errorf("invalid wisdom: %v", err)
}
if hero.Charisma, err = strconv.Atoi(fields[indices.Charisma]); err != nil {
return nil, fmt.Errorf("invalid charisma: %v", err)
}
if hero.Sum, err = strconv.Atoi(fields[indices.Sum]); err != nil {
return nil, fmt.Errorf("invalid sum: %v", err)
}
if hero.Vigor, err = strconv.Atoi(fields[indices.Vigor]); err != nil {
return nil, fmt.Errorf("invalid vigor: %v", err)
}
if hero.Integrity, err = strconv.Atoi(fields[indices.Integrity]); err != nil {
return nil, fmt.Errorf("invalid integrity: %v", err)
}
if hero.Ambition, err = strconv.Atoi(fields[indices.Ambition]); err != nil {
return nil, fmt.Errorf("invalid ambition: %v", err)
}
if hero.Gregariousness, err = strconv.Atoi(fields[indices.Gregariousness]); err != nil {
return nil, fmt.Errorf("invalid gregariousness: %v", err)
}
if hero.Bravery, err = strconv.Atoi(fields[indices.Bravery]); err != nil {
return nil, fmt.Errorf("invalid bravery: %v", err)
}
// Parse boolean field
switch strings.ToLower(fields[indices.GreatPerson]) {
case "true":
hero.GreatPerson = true
case "false":
hero.GreatPerson = false
default:
return nil, fmt.Errorf("invalid great person value: %s", fields[indices.GreatPerson])
}
return hero, nil
}
func printHero(lineNum int, hero *Hero) {
fmt.Printf("Hero #%d:\n", lineNum)
fmt.Printf(" Name: %s\n", hero.Name)
fmt.Printf(" Gender: %s\n", hero.Gender)
fmt.Printf(" Profession: %s\n", hero.Profession)
fmt.Printf(" Great Person: %t\n", hero.GreatPerson)
fmt.Printf(" Image Path: %s\n", hero.ImagePath)
fmt.Printf(" Stats:\n")
fmt.Printf(" Strength: %d, Agility: %d, Constitution: %d\n", hero.Strength, hero.Agility, hero.Constitution)
fmt.Printf(" Wisdom: %d, Charisma: %d, Sum: %d\n", hero.Wisdom, hero.Charisma, hero.Sum)
fmt.Printf(" Vigor: %d, Integrity: %d, Ambition: %d\n", hero.Vigor, hero.Integrity, hero.Ambition)
fmt.Printf(" Gregariousness: %d, Bravery: %d\n", hero.Gregariousness, hero.Bravery)
fmt.Printf(" Personality: %s\n", hero.Personality)
fmt.Printf(" Backstory: %.100s", hero.Backstory)
if len(hero.Backstory) > 100 {
fmt.Printf("...")
}
fmt.Println()
}
@@ -0,0 +1,15 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "heroformatter_lib",
srcs = ["main.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/util/hero_generation/heroformatter",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/tsv"],
)
go_binary(
name = "heroformatter",
embed = [":heroformatter_lib"],
visibility = ["//visibility:public"],
)

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