mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 03:45:41 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6882fabc95 | ||
|
|
84c1bf61b0 | ||
|
|
21dfa12197 | ||
|
|
de4c568aa0 | ||
|
|
edccde9a90 | ||
|
|
0f7f019f14 | ||
|
|
adcafe3224 | ||
|
|
89ad8371b5 | ||
|
|
b8c584572d | ||
|
|
acb411c4d2 | ||
|
|
25f92c7832 | ||
|
|
0f0d2433bb | ||
|
|
eea61f1c6a | ||
|
|
559f051efa | ||
|
|
a2e136af3d | ||
|
|
c4f887255b | ||
|
|
97b313f6d5 | ||
|
|
13e9fb5dad | ||
|
|
6936c36fc1 | ||
|
|
2db4db0ba3 | ||
|
|
5abfdc1f09 | ||
|
|
5c5cf2ce43 | ||
|
|
a31aa0a46d | ||
|
|
63f0119a02 | ||
|
|
8436106c7b | ||
|
|
62f4d40ef3 | ||
|
|
5a650cb9b7 | ||
|
|
643765a2ba | ||
|
|
91553a0eec | ||
|
|
e1384fffd1 | ||
|
|
1f3c4bfef0 | ||
|
|
bfd48bd628 | ||
|
|
12bd6a78b1 | ||
|
|
420edb15ae | ||
|
|
8c0ac6e0fa | ||
|
|
81ce184e17 | ||
|
|
9a4324e302 | ||
|
|
473f83e5f6 | ||
|
|
7d7fa53f46 | ||
|
|
56df35a3b7 | ||
|
|
b93a2ad1ce | ||
|
|
8213c733a5 |
@@ -36,8 +36,6 @@ jobs:
|
||||
- name: Check BUILD.bazel dependencies
|
||||
run: ./scripts/check_build_deps.sh --strict
|
||||
- name: Run tests
|
||||
id: test
|
||||
continue-on-error: true
|
||||
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
|
||||
- name: Collect failed test logs
|
||||
if: always()
|
||||
@@ -86,6 +84,3 @@ jobs:
|
||||
path: failed_test_logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 5
|
||||
- name: Fail if tests failed
|
||||
if: steps.test.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
@@ -6,9 +6,11 @@ on:
|
||||
paths:
|
||||
# Note: C++ changes trigger shardok_arm64_build.yml instead
|
||||
# Note: Auth changes trigger auth_build.yml instead
|
||||
# Note: Windows installer changes trigger installer_build.yml instead
|
||||
- 'src/main/go/**'
|
||||
- '!src/main/go/net/eagle0/authservice/**'
|
||||
- '!src/main/go/net/eagle0/authcli/**'
|
||||
- '!src/main/go/net/eagle0/clients/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
||||
@@ -159,6 +161,8 @@ jobs:
|
||||
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
||||
@@ -213,7 +217,7 @@ jobs:
|
||||
# Copy files
|
||||
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
||||
|
||||
- name: Deploy to production droplet
|
||||
@@ -277,6 +281,8 @@ jobs:
|
||||
export ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
export OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
|
||||
export GEMINI_API_KEY="${GEMINI_API_KEY}"
|
||||
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
|
||||
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
|
||||
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
||||
@@ -368,6 +374,8 @@ jobs:
|
||||
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
# Create eagle-exec alias (symlink in /usr/local/bin)
|
||||
sudo ln -sf /opt/eagle0/scripts/eagle-exec.sh /usr/local/bin/eagle-exec
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
|
||||
|
||||
|
||||
@@ -37,15 +37,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build Windows installer with manifest public key injected via x_defs
|
||||
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64 --stamp
|
||||
# Build Windows installer with WebView GUI (uses CGO cross-compilation)
|
||||
# Use --action_env to pass the signing key into the genrule sandbox
|
||||
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview --stamp --action_env=MANIFEST_PUBLIC_KEY
|
||||
|
||||
# Copy to output directory
|
||||
rm -rf ./installer-output
|
||||
mkdir -p ./installer-output
|
||||
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/EagleInstaller.exe ./installer-output/EagleInstaller.exe
|
||||
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/Eagle0.exe ./installer-output/Eagle0.exe
|
||||
|
||||
echo "Go installer size: $(ls -lh ./installer-output/EagleInstaller.exe | awk '{print $5}')"
|
||||
echo "Go installer size: $(ls -lh ./installer-output/Eagle0.exe | awk '{print $5}')"
|
||||
|
||||
- name: Archive installer binary
|
||||
if: success() || failure()
|
||||
@@ -61,8 +62,8 @@ jobs:
|
||||
echo "=== Installer output directory ==="
|
||||
ls -lh ./installer-output/
|
||||
|
||||
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
|
||||
echo "ERROR: EagleInstaller.exe not found"
|
||||
if [ ! -f "./installer-output/Eagle0.exe" ]; then
|
||||
echo "ERROR: Eagle0.exe not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installer found"
|
||||
@@ -73,9 +74,9 @@ jobs:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
|
||||
echo "Deploying Go installer to installer/EagleInstaller.exe"
|
||||
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
|
||||
INSTALLER_PATH="$(pwd)/installer-output/Eagle0.exe"
|
||||
echo "Deploying Go installer to installer/Eagle0.exe"
|
||||
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH" "installer/Eagle0.exe"
|
||||
|
||||
- name: Update manifest
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
@@ -84,9 +85,9 @@ jobs:
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/Eagle0.exe | cut -d' ' -f1)
|
||||
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
|
||||
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
|
||||
echo "installer_url=installer/Eagle0.exe" >> /tmp/installer_manifest.txt
|
||||
|
||||
echo "=== Manifest content ==="
|
||||
cat /tmp/installer_manifest.txt
|
||||
|
||||
@@ -107,6 +107,7 @@ bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
go_sdk.download(version = "1.23.3")
|
||||
use_repo(go_sdk, "go_default_sdk")
|
||||
|
||||
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
|
||||
go_deps.from_file(go_mod = "//:go.mod")
|
||||
@@ -118,6 +119,7 @@ use_repo(
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"com_github_golang_jwt_jwt_v5",
|
||||
"com_github_google_uuid",
|
||||
"com_github_webview_webview_go",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
)
|
||||
@@ -336,6 +338,19 @@ http_file(
|
||||
executable = True,
|
||||
)
|
||||
|
||||
# LLVM MinGW toolchain for Windows cross-compilation from macOS
|
||||
# This provides a complete toolchain for building Windows executables including
|
||||
# the MinGW-w64 libraries needed for CGO cross-compilation
|
||||
LLVM_MINGW_VERSION = "20250305"
|
||||
|
||||
http_archive(
|
||||
name = "llvm_mingw",
|
||||
build_file = "@//external:BUILD.llvm_mingw",
|
||||
sha256 = "32c24fc62fc8b9f8a900bf2c730b78b36767688f816f9d21e97a168289ff44e0",
|
||||
strip_prefix = "llvm-mingw-%s-ucrt-macos-14.4.1-universal" % LLVM_MINGW_VERSION,
|
||||
urls = ["https://github.com/fathonix/llvm-mingw-arm64ec-macos/releases/download/%s/llvm-mingw-%s-ucrt-macos-14.4.1-universal.tar.xz" % (LLVM_MINGW_VERSION, LLVM_MINGW_VERSION)],
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
|
||||
Generated
+561
@@ -611,6 +611,445 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@aspect_rules_js~//npm:extensions.bzl%pnpm": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "T22SdPzhLxF1CM+j9RD/Rq03yJ0NDfH2eE6hAbUcOII=",
|
||||
"usagesDigest": "6rWte4KDbiluq1s7w98bc4+2NjA8w67DKHDj4+DNw/Y=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"pnpm": {
|
||||
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
|
||||
"ruleClassName": "npm_import_rule",
|
||||
"attributes": {
|
||||
"package": "pnpm",
|
||||
"version": "8.6.7",
|
||||
"root_package": "",
|
||||
"link_workspace": "",
|
||||
"link_packages": {},
|
||||
"integrity": "sha512-vRIWpD/L4phf9Bk2o/O2TDR8fFoJnpYrp2TKqTIZF/qZ2/rgL3qKXzHofHgbXsinwMoSEigz28sqk3pQ+yMEQQ==",
|
||||
"url": "",
|
||||
"commit": "",
|
||||
"patch_args": [
|
||||
"-p0"
|
||||
],
|
||||
"patches": [],
|
||||
"custom_postinstall": "",
|
||||
"npm_auth": "",
|
||||
"npm_auth_basic": "",
|
||||
"npm_auth_username": "",
|
||||
"npm_auth_password": "",
|
||||
"lifecycle_hooks": [],
|
||||
"extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])",
|
||||
"generate_bzl_library_targets": false,
|
||||
"extract_full_archive": true,
|
||||
"exclude_package_contents": [],
|
||||
"system_tar": "auto"
|
||||
}
|
||||
},
|
||||
"pnpm__links": {
|
||||
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
|
||||
"ruleClassName": "npm_import_links",
|
||||
"attributes": {
|
||||
"package": "pnpm",
|
||||
"version": "8.6.7",
|
||||
"dev": false,
|
||||
"root_package": "",
|
||||
"link_packages": {},
|
||||
"deps": {},
|
||||
"transitive_closure": {},
|
||||
"lifecycle_build_target": false,
|
||||
"lifecycle_hooks_env": [],
|
||||
"lifecycle_hooks_execution_requirements": [
|
||||
"no-sandbox"
|
||||
],
|
||||
"lifecycle_hooks_use_default_shell_env": false,
|
||||
"bins": {},
|
||||
"package_visibility": [
|
||||
"//visibility:public"
|
||||
],
|
||||
"replace_package": "",
|
||||
"exclude_package_contents": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_lib",
|
||||
"bazel_lib~"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"tar.bzl",
|
||||
"tar.bzl~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"aspect_bazel_lib",
|
||||
"aspect_bazel_lib~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"aspect_rules_js",
|
||||
"aspect_rules_js~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"bazel_features",
|
||||
"bazel_features~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"bazel_features~",
|
||||
"bazel_features_globals",
|
||||
"bazel_features~~version_extension~bazel_features_globals"
|
||||
],
|
||||
[
|
||||
"bazel_features~",
|
||||
"bazel_features_version",
|
||||
"bazel_features~~version_extension~bazel_features_version"
|
||||
],
|
||||
[
|
||||
"bazel_lib~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"bazel_lib~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"tar.bzl~",
|
||||
"aspect_bazel_lib",
|
||||
"aspect_bazel_lib~"
|
||||
],
|
||||
[
|
||||
"tar.bzl~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"tar.bzl~",
|
||||
"tar.bzl",
|
||||
"tar.bzl~"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@aspect_rules_ts~//ts:extensions.bzl%ext": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "h1hftyFCdJgiHD9blfFcMAiEk5ltEoUdNkuHPIkg/hM=",
|
||||
"usagesDigest": "v0aTa4/gasWF2bvssXYr1bqcYWc3kjV48hcj0z2QVT0=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"npm_typescript": {
|
||||
"bzlFile": "@@aspect_rules_ts~//ts/private:npm_repositories.bzl",
|
||||
"ruleClassName": "http_archive_version",
|
||||
"attributes": {
|
||||
"bzlmod": true,
|
||||
"version": "5.8.3",
|
||||
"integrity": "",
|
||||
"build_file": "@@aspect_rules_ts~//ts:BUILD.typescript",
|
||||
"build_file_substitutions": {
|
||||
"bazel_worker_version": "5.4.2",
|
||||
"google_protobuf_version": "3.20.1"
|
||||
},
|
||||
"urls": [
|
||||
"https://registry.npmjs.org/typescript/-/typescript-{}.tgz"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"aspect_rules_ts~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@cel-spec~//:extensions.bzl%non_module_dependencies": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "/2uyuQa5purSharolRXYOGYMGSGjDeByo6JfQDWsraA=",
|
||||
"usagesDigest": "2f6juplOpWu+UdD1kVgi773xavnFQ+OcH0PRuQduDxY=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"com_google_googleapis": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"sha256": "bd8e735d881fb829751ecb1a77038dda4a8d274c45490cb9fcf004583ee10571",
|
||||
"strip_prefix": "googleapis-07c27163ac591955d736f3057b1619ece66f5b99",
|
||||
"urls": [
|
||||
"https://github.com/googleapis/googleapis/archive/07c27163ac591955d736f3057b1619ece66f5b99.tar.gz"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"cel-spec~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@cel-spec~//:googleapis_ext.bzl%googleapis_ext": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "yun2jmsomFi3bs5bjQWXApBzqQf66zBJ39JEBYigzdc=",
|
||||
"usagesDigest": "OK8FsLndSl2AbwGM1Npe5NdHR1kDAebcw7Ee+KkekE0=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"com_google_googleapis_imports": {
|
||||
"bzlFile": "@@cel-spec~~non_module_dependencies~com_google_googleapis//:repository_rules.bzl",
|
||||
"ruleClassName": "switched_rules",
|
||||
"attributes": {
|
||||
"rules": {
|
||||
"proto_library_with_info": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"moved_proto_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"java_proto_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"java_grpc_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"java_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"java_gapic_test": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"java_gapic_assembly_gradle_pkg": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"py_proto_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"py_grpc_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"py_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"py_test": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"py_gapic_assembly_pkg": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"py_import": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"go_proto_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"go_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"go_test": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"go_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"go_gapic_assembly_pkg": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"cc_proto_library": [
|
||||
"native.cc_proto_library",
|
||||
""
|
||||
],
|
||||
"cc_grpc_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"cc_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"php_proto_library": [
|
||||
"",
|
||||
"php_proto_library"
|
||||
],
|
||||
"php_grpc_library": [
|
||||
"",
|
||||
"php_grpc_library"
|
||||
],
|
||||
"php_gapic_library": [
|
||||
"",
|
||||
"php_gapic_library"
|
||||
],
|
||||
"php_gapic_assembly_pkg": [
|
||||
"",
|
||||
"php_gapic_assembly_pkg"
|
||||
],
|
||||
"nodejs_gapic_library": [
|
||||
"",
|
||||
"typescript_gapic_library"
|
||||
],
|
||||
"nodejs_gapic_assembly_pkg": [
|
||||
"",
|
||||
"typescript_gapic_assembly_pkg"
|
||||
],
|
||||
"ruby_proto_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"ruby_grpc_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"ruby_ads_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"ruby_cloud_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"ruby_gapic_assembly_pkg": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"csharp_proto_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"csharp_grpc_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"csharp_gapic_library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"csharp_gapic_assembly_pkg": [
|
||||
"",
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"cel-spec~",
|
||||
"com_google_googleapis",
|
||||
"cel-spec~~non_module_dependencies~com_google_googleapis"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@envoy_api~//bazel:repositories.bzl%non_module_deps": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "6TqmRfVELxZJRPQYuJpC4JBX4QvdrHqTDOUJGOGODSo=",
|
||||
"usagesDigest": "IivjlawPvqhHPUJ3c6dLiPsH22mn/g/dJtlmv3zdimM=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"prometheus_metrics_model": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"urls": [
|
||||
"https://github.com/prometheus/client_model/archive/v0.6.1.tar.gz"
|
||||
],
|
||||
"sha256": "b9b690bc35d80061f255faa7df7621eae39fe157179ccd78ff6409c3b004f05e",
|
||||
"strip_prefix": "client_model-0.6.1",
|
||||
"build_file_content": "\nload(\"@envoy_api//bazel:api_build_system.bzl\", \"api_cc_py_proto_library\")\nload(\"@io_bazel_rules_go//proto:def.bzl\", \"go_proto_library\")\n\napi_cc_py_proto_library(\n name = \"client_model\",\n srcs = [\n \"io/prometheus/client/metrics.proto\",\n ],\n visibility = [\"//visibility:public\"],\n)\n\ngo_proto_library(\n name = \"client_model_go_proto\",\n importpath = \"github.com/prometheus/client_model/go\",\n proto = \":client_model\",\n visibility = [\"//visibility:public\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"com_github_bufbuild_buf": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"urls": [
|
||||
"https://github.com/bufbuild/buf/releases/download/v1.49.0/buf-Linux-x86_64.tar.gz"
|
||||
],
|
||||
"sha256": "ee8da9748249f7946d79191e36469ce7bc3b8ba80019bff1fa4289a44cbc23bf",
|
||||
"strip_prefix": "buf",
|
||||
"build_file_content": "\npackage(\n default_visibility = [\"//visibility:public\"],\n)\n\nfilegroup(\n name = \"buf\",\n srcs = [\n \"@com_github_bufbuild_buf//:bin/buf\",\n ],\n tags = [\"manual\"], # buf is downloaded as a linux binary; tagged manual to prevent build for non-linux users\n)\n"
|
||||
}
|
||||
},
|
||||
"envoy_toolshed": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"urls": [
|
||||
"https://github.com/envoyproxy/toolshed/archive/bazel-v0.2.2.tar.gz"
|
||||
],
|
||||
"sha256": "443fe177aba0cef8c17b7a48905c925c67b09005b10dd70ff12cd9f729a72d51",
|
||||
"strip_prefix": "toolshed-bazel-v0.2.2/bazel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"envoy_api~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"envoy_api~",
|
||||
"envoy_api",
|
||||
"envoy_api~"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@googleapis~//:extensions.bzl%switched_rules": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "vG6fuTzXD8MMvHWZEQud0MMH7eoC4GXY0va7VrFFh04=",
|
||||
@@ -779,6 +1218,37 @@
|
||||
"recordedRepoMappingEntries": []
|
||||
}
|
||||
},
|
||||
"@@pybind11_bazel~//:internal_configure.bzl%internal_configure_extension": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "CyAKLVVonohnkTSqg9II/HA7M49sOlnMkgMHL3CmDuc=",
|
||||
"usagesDigest": "mFrTHX5eCiNU/OIIGVHH3cOILY9Zmjqk8RQYv8o6Thk=",
|
||||
"recordedFileInputs": {
|
||||
"@@pybind11_bazel~//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
|
||||
},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"pybind11": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"build_file": "@@pybind11_bazel~//:pybind11-BUILD.bazel",
|
||||
"strip_prefix": "pybind11-2.12.0",
|
||||
"urls": [
|
||||
"https://github.com/pybind/pybind11/archive/v2.12.0.zip"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"pybind11_bazel~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_foreign_cc~//foreign_cc:extensions.bzl%tools": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "a7qnESofmIRYId6wwGNPJ9kvExU80KrkxL281P3+lBE=",
|
||||
@@ -1119,6 +1589,97 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_fuzzing~//fuzzing/private:extensions.bzl%non_module_dependencies": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "hVgJRQ3Er45/UUAgNn1Yp2Khcp/Y8WyafA2kXIYmQ5M=",
|
||||
"usagesDigest": "YnIrdgwnf3iCLfChsltBdZ7yOJh706lpa2vww/i2pDI=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"platforms": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"urls": [
|
||||
"https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz",
|
||||
"https://github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz"
|
||||
],
|
||||
"sha256": "8150406605389ececb6da07cbcb509d5637a3ab9a24bc69b1101531367d89d74"
|
||||
}
|
||||
},
|
||||
"rules_python": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"sha256": "d70cd72a7a4880f0000a6346253414825c19cdd40a28289bdf67b8e6480edff8",
|
||||
"strip_prefix": "rules_python-0.28.0",
|
||||
"url": "https://github.com/bazelbuild/rules_python/releases/download/0.28.0/rules_python-0.28.0.tar.gz"
|
||||
}
|
||||
},
|
||||
"bazel_skylib": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"sha256": "cd55a062e763b9349921f0f5db8c3933288dc8ba4f76dd9416aac68acee3cb94",
|
||||
"urls": [
|
||||
"https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz",
|
||||
"https://github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz"
|
||||
]
|
||||
}
|
||||
},
|
||||
"com_google_absl": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"urls": [
|
||||
"https://github.com/abseil/abseil-cpp/archive/refs/tags/20240116.1.zip"
|
||||
],
|
||||
"strip_prefix": "abseil-cpp-20240116.1",
|
||||
"integrity": "sha256-7capMWOvWyoYbUaHF/b+I2U6XLMaHmky8KugWvfXYuk="
|
||||
}
|
||||
},
|
||||
"rules_fuzzing_oss_fuzz": {
|
||||
"bzlFile": "@@rules_fuzzing~//fuzzing/private/oss_fuzz:repository.bzl",
|
||||
"ruleClassName": "oss_fuzz_repository",
|
||||
"attributes": {}
|
||||
},
|
||||
"honggfuzz": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_archive",
|
||||
"attributes": {
|
||||
"build_file": "@@rules_fuzzing~//:honggfuzz.BUILD",
|
||||
"sha256": "6b18ba13bc1f36b7b950c72d80f19ea67fbadc0ac0bb297ec89ad91f2eaa423e",
|
||||
"url": "https://github.com/google/honggfuzz/archive/2.5.zip",
|
||||
"strip_prefix": "honggfuzz-2.5"
|
||||
}
|
||||
},
|
||||
"rules_fuzzing_jazzer": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_jar",
|
||||
"attributes": {
|
||||
"sha256": "ee6feb569d88962d59cb59e8a31eb9d007c82683f3ebc64955fd5b96f277eec2",
|
||||
"url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer/0.20.1/jazzer-0.20.1.jar"
|
||||
}
|
||||
},
|
||||
"rules_fuzzing_jazzer_api": {
|
||||
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
|
||||
"ruleClassName": "http_jar",
|
||||
"attributes": {
|
||||
"sha256": "f5a60242bc408f7fa20fccf10d6c5c5ea1fcb3c6f44642fec5af88373ae7aa1b",
|
||||
"url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer-api/0.20.1/jazzer-api-0.20.1.jar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"rules_fuzzing~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_java~//java:rules_java_deps.bzl%compatibility_proxy": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "KIX40nDfygEWbU+rq3nYpt3tVgTK/iO8PKh5VMBlN7M=",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
/bin/mkdir -p win_output
|
||||
/usr/bin/dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller.sln -o win_output
|
||||
SHA=`sha256sum /tmp/EagleInstaller.exe | awk '{print $1 }'`
|
||||
ZIP_FILE="updater__$SHA.zip"
|
||||
|
||||
/usr/bin/zip win_output/$ZIP_FILE win_output/EagleInstaller.exe
|
||||
rm win_output/EagleInstaller.exe
|
||||
rm win_output/EagleInstaller.pdb
|
||||
|
||||
DATE=`date +"%Y-%m-%d %T"`
|
||||
|
||||
cat > win_output/updater.html <<-EOF
|
||||
<html>
|
||||
<head>
|
||||
<title>Download Eagle Updater</title>
|
||||
</head>
|
||||
<body>
|
||||
<a href="http://eagle0.net/assets/$ZIP_FILE">$ZIP_FILE</a> (updated $DATE)
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
SSH_KEY_FILE=$1
|
||||
SSH_USER_NAME=$2
|
||||
|
||||
/usr/bin/rsync -r --copy-links -e "/usr/bin/ssh -i $SSH_KEY_FILE -p 9022" win_output/ $SSH_USER_NAME@eagle0.net:/www/assets/
|
||||
@@ -18,8 +18,6 @@ services:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-blue
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS}"
|
||||
- "--auth-service-url"
|
||||
@@ -28,6 +26,8 @@ services:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
@@ -67,8 +67,6 @@ services:
|
||||
container_name: eagle-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS}"
|
||||
- "--auth-service-url"
|
||||
@@ -77,6 +75,8 @@ services:
|
||||
- "40034:40032" # Different host port for staging
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
## Vision
|
||||
|
||||
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ GRPC BOUNDARY │
|
||||
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SCALA ENGINE │
|
||||
│ │
|
||||
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
|
||||
│ ↑ │ │
|
||||
│ │ (Pure Scala models) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE BOUNDARY │
|
||||
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State (January 2026)
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Production code (`src/main/scala/.../library/`) | ✅ **Complete** - zero proto imports |
|
||||
| Test code (`src/test/scala/.../library/`) | ✅ **Complete** - 24 migrated, 0 deferred, 4 deleted |
|
||||
| Hostility proto migration | ✅ **Complete** - all 4 files migrated to Scala enum |
|
||||
|
||||
**Note:** The `bazel query` for proto dependencies still shows ~26 targets, but these are transitive build-graph dependencies from map data generation (C++/Go tools), not Scala code imports.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Library Test Migration ✅ Complete
|
||||
|
||||
### Overview
|
||||
|
||||
27 test files in `src/test/scala/.../library/` were reviewed for proto type usage. 20 files were successfully migrated to use Scala model types exclusively.
|
||||
|
||||
### Migration Results
|
||||
|
||||
#### Successfully Migrated (20 files)
|
||||
|
||||
| File | PR | Notes |
|
||||
|------|-----|-------|
|
||||
| `QuestFulfillmentUtilsTest.scala` | Merged | Light migration |
|
||||
| `FeastCommandTest.scala` | Merged | Light migration |
|
||||
| `FriendlyMoveActionTest.scala` | Merged | Light migration |
|
||||
| `DeclineQuestCommandTest.scala` | Merged | Light migration |
|
||||
| `PerformHostileArmySetupActionTest.scala` | Merged | Medium migration |
|
||||
| `NewYearActionTest.scala` | Merged | Medium migration |
|
||||
| `ProvinceHeldActionTest.scala` | Merged | Medium migration |
|
||||
| `AvailableMarchCommandFactoryTest.scala` | Merged | Medium migration |
|
||||
| `AvailableCommandsFactoryTest.scala` | Merged | Heavy migration |
|
||||
| `AvailablePleaseRecruitMeCommandFactoryTest.scala` | Merged | Heavy migration |
|
||||
| `PerformReconResolutionActionTest.scala` | Merged | Heavy migration |
|
||||
| `AttackDecisionCommandChooserTest.scala` | Merged | Heavy migration |
|
||||
| `PerformProvinceEventsActionTest.scala` | #5499 | Heavy migration |
|
||||
| `PerformFoodConsumptionPhaseActionTest.scala` | #5500 | Heavy migration |
|
||||
| `AvailableDiplomacyCommandsFactoryTest.scala` | #5502 | Heavy migration |
|
||||
| `EndPlayerCommandsPhaseActionTest.scala` | #5504 | Heavy migration |
|
||||
| `ProvinceConqueredActionTest.scala` | #5505 | Heavy migration |
|
||||
| `NewRoundActionTest.scala` | #5506 | Heavy migration (22 imports) |
|
||||
| `EngineImplTest.scala` | #5488 | Added InMemoryHistory.fromScalaResults |
|
||||
| `GameStateViewFilterTest.scala` | #5510 | Heavy migration with view types |
|
||||
|
||||
#### Additional Migrations (4 files)
|
||||
|
||||
| File | PR | Notes |
|
||||
|------|-----|-------|
|
||||
| `BattalionTypesTestData.scala` | hostility-deproto | Migrated to Scala BattalionType |
|
||||
| `NewRoundActionTest.scala` | hostility-deproto | Heavy migration (FactionRelationship, MovingArmy) |
|
||||
| `EndBattleAftermathPhaseActionTest.scala` | hostility-deproto | Complete rewrite to Scala types |
|
||||
| `ResolveBattleActionTest.scala` | hostility-deproto | Minimal migration (removed IDable usage) |
|
||||
|
||||
#### Deleted (4 files)
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `RansomCommandTest.scala` | Orphaned test with no corresponding implementation |
|
||||
| `IDable.scala` | Unused - BUILD deps existed but no code actually imported it |
|
||||
| `impl/package.scala` | Defined HeroMap/BattalionMap but never used |
|
||||
| `availability/package.scala` | Defined HeroMap/BattalionMap but never used |
|
||||
|
||||
### Key Migration Patterns Used
|
||||
|
||||
**Import aliasing to avoid naming conflicts:**
|
||||
```scala
|
||||
import net.eagle0.eagle.model.action_result.types.ActionResultType.{
|
||||
NewRoundAction as NewRoundActionType,
|
||||
NewYearAction as NewYearActionType
|
||||
}
|
||||
```
|
||||
|
||||
**Using `inside()` for concrete type access:**
|
||||
```scala
|
||||
inside(result.changedProvinces.head) { case cp: ChangedProvinceC =>
|
||||
cp.foodDelta shouldBe Some(-258)
|
||||
}
|
||||
```
|
||||
|
||||
**Scala model construction:**
|
||||
```scala
|
||||
HeroC(id = 9, vigor = 50, factionId = Some(actingFactionId))
|
||||
ProvinceC(id = 7, rulingFactionId = Some(5), food = 2987)
|
||||
FactionC(id = 5, factionHeadId = 9, name = "Test Faction")
|
||||
```
|
||||
|
||||
### Key Conversions Reference
|
||||
|
||||
| Proto Type | Scala Equivalent |
|
||||
|------------|------------------|
|
||||
| `GameState` | `GameState` (model/state/game_state) |
|
||||
| `Province` | `ProvinceC` |
|
||||
| `Hero` | `HeroC` |
|
||||
| `Faction` | `FactionC` |
|
||||
| `Battalion` | `BattalionC` |
|
||||
| `Army` | `Army` (already Scala) |
|
||||
| `Date` | `Date` (model/state/date) |
|
||||
| `ChangedHero` | `ChangedHeroC` |
|
||||
| `ChangedProvince` | `ChangedProvinceC` |
|
||||
| `ActionResultType.*` | `ActionResultType.*` (Scala enum) |
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- [x] 24 test files fully migrated to Scala types
|
||||
- [x] All tests pass
|
||||
- [x] Deferred files documented with clear rationale
|
||||
- [x] Zero proto imports in `src/test/scala/.../library/` ✅
|
||||
|
||||
---
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
### Fully Protoless ✅
|
||||
|
||||
- **Production library code** (`src/main/scala/.../library/`)
|
||||
- **AI layer** (`/ai/`)
|
||||
- **Actions** (`/library/actions/impl/action/`)
|
||||
- **Commands** (`/library/actions/impl/command/`)
|
||||
- **Availability** (`/library/actions/availability/`)
|
||||
- **Command helpers** (`/library/util/command_choice_helpers/`)
|
||||
- **View filters** (`/library/util/view_filters/`)
|
||||
- **LLM generators** (`/library/actions/llm_prompt_generators/`)
|
||||
- **All library tests** (`src/test/scala/.../library/`) - 24 files migrated
|
||||
|
||||
### Proto at Boundaries (Expected) ✅
|
||||
|
||||
- `EagleServiceImpl.scala` - gRPC boundary
|
||||
- `GameController.scala` - Client communication
|
||||
- `*Converter.scala` - Explicit conversion utilities
|
||||
- `PersistedHistory.scala` - Disk persistence
|
||||
|
||||
### Deferred (Documented) 📋
|
||||
|
||||
- None! All library test utilities migrated or deleted.
|
||||
@@ -0,0 +1,94 @@
|
||||
# LLM Model Comparison
|
||||
|
||||
This document compares streaming latency (time-to-first-token) and pricing across OpenAI, Anthropic (Claude), and Google (Gemini) models for use in Eagle's narrative text generation.
|
||||
|
||||
## Test Methodology
|
||||
|
||||
All tests were performed locally using curl with streaming enabled. Each model was tested 3 times with the same prompt:
|
||||
|
||||
> "Write a short paragraph about a brave knight who discovers a hidden cave. Make it vivid and descriptive."
|
||||
|
||||
Time-to-first-token (TTFT) was measured from request initiation to the first text content appearing in the stream.
|
||||
|
||||
## Streaming Latency Results (January 2026)
|
||||
|
||||
| Model | Run 1 | Run 2 | Run 3 | Average TTFT |
|
||||
|-------|-------|-------|-------|--------------|
|
||||
| **Gemini 2.5 Flash-Lite** | 0.76s | 0.54s | 0.53s | **~0.6s** |
|
||||
| **gpt-4.1-mini** | 1.65s | 1.72s | 1.68s | **~1.7s** |
|
||||
| **claude-3-5-haiku** | 1.85s | 1.92s | 1.88s | **~1.9s** |
|
||||
| gpt-5.2 | 3.25s | 3.38s | 3.32s | **~3.3s** |
|
||||
| gpt-5-mini | 2.52s | 5.82s | 3.12s | **~3.8s** (high variance) |
|
||||
| Gemini 3 Flash Preview | 4.11s | 4.77s | 4.28s | **~4.4s** |
|
||||
| claude-sonnet-4 | 4.89s | 5.12s | 4.98s | **~5.0s** |
|
||||
| Gemini 2.5 Flash | 5.60s | 7.00s | 7.79s | **~6.8s** |
|
||||
|
||||
## Pricing Comparison (per 1M tokens)
|
||||
|
||||
| Model | Input Price | Output Price | Notes |
|
||||
|-------|-------------|--------------|-------|
|
||||
| **Gemini 2.5 Flash-Lite** | $0.10 | $0.40 | Cheapest and fastest |
|
||||
| Gemini 2.5 Flash | $0.15 | $0.60 | |
|
||||
| gpt-5-mini | $0.25 | $2.00 | |
|
||||
| **gpt-4.1-mini** | $0.40 | $1.60 | Best OpenAI value |
|
||||
| Gemini 3 Flash Preview | $0.50 | $3.00 | Includes thinking tokens |
|
||||
| **claude-3-5-haiku** | $0.80 | $4.00 | Best Anthropic value |
|
||||
| gpt-5.2 | ~$1.00 | ~$10.00 | Full reasoning model |
|
||||
| Gemini 2.5 Pro | $1.25 | $10.00 | |
|
||||
| Gemini 3 Pro Preview | $2.00 | $12.00 | ≤200K context |
|
||||
| claude-sonnet-4 | $3.00 | $15.00 | |
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Narrative Text Generation (Default)
|
||||
|
||||
**Gemini 2.5 Flash-Lite** is recommended as the default:
|
||||
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
|
||||
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
|
||||
- Quality is acceptable for short narrative snippets
|
||||
|
||||
### Alternative Options
|
||||
|
||||
| Priority | Model | When to Use |
|
||||
|----------|-------|-------------|
|
||||
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
|
||||
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
|
||||
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
|
||||
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
|
||||
|
||||
### Quality Trade-offs
|
||||
|
||||
For short narrative snippets (1-3 paragraphs):
|
||||
- **Flash-Lite vs Haiku/4.1-mini**: Minor quality difference, significant speed gain
|
||||
- **Haiku vs Sonnet**: Noticeable quality difference in creative writing variety
|
||||
- **gpt-4.1-mini vs gpt-5.2**: Moderate quality difference, significant cost savings
|
||||
|
||||
## Configuration
|
||||
|
||||
LLM settings can be changed at runtime via the admin console:
|
||||
|
||||
1. Navigate to Admin Console → Settings
|
||||
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
|
||||
3. Change the corresponding model name setting:
|
||||
- `GeminiModelName` (default: gemini-2.5-flash-lite)
|
||||
- `OpenAiModelName` (default: gpt-4.1-mini)
|
||||
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
|
||||
|
||||
Changes take effect on the next LLM request.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For production deployment, ensure API keys are set:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GEMINI_API_KEY=AIza...
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **gpt-5-mini** showed high latency variance (2.5s - 5.8s) in testing
|
||||
- **Gemini 2.5 Flash** was surprisingly slower than Flash-Lite, possibly due to internal reasoning overhead
|
||||
- **Gemini 3 Flash** is a frontier model with better quality but higher latency than 2.5 Flash-Lite
|
||||
- All Gemini models have a generous free tier (up to 1,000 daily requests)
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
# LLVM MinGW toolchain for Windows cross-compilation
|
||||
# Provides x86_64-w64-mingw32 target compiler and libraries
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
filegroup(
|
||||
name = "all_files",
|
||||
srcs = glob(["**/*"]),
|
||||
)
|
||||
|
||||
# Compiler binaries
|
||||
filegroup(
|
||||
name = "compiler_files",
|
||||
srcs = glob([
|
||||
"bin/x86_64-w64-mingw32-*",
|
||||
"bin/clang*",
|
||||
"bin/llvm-*",
|
||||
"bin/lld*",
|
||||
]),
|
||||
)
|
||||
|
||||
# Windows x86_64 sysroot (headers and libraries)
|
||||
filegroup(
|
||||
name = "windows_x86_64_sysroot",
|
||||
srcs = glob([
|
||||
"x86_64-w64-mingw32/**/*",
|
||||
"generic-w64-mingw32/include/**/*",
|
||||
]),
|
||||
)
|
||||
|
||||
# All library files needed for linking
|
||||
filegroup(
|
||||
name = "linker_files",
|
||||
srcs = glob([
|
||||
"bin/x86_64-w64-mingw32-*",
|
||||
"bin/lld*",
|
||||
"bin/ld.lld*",
|
||||
"lib/**/*",
|
||||
"x86_64-w64-mingw32/lib/**/*",
|
||||
]),
|
||||
)
|
||||
|
||||
# The main C compiler wrapper script path for CGO
|
||||
# CGO needs CC to point to the cross-compiler
|
||||
exports_files([
|
||||
"bin/x86_64-w64-mingw32-clang",
|
||||
"bin/x86_64-w64-mingw32-clang++",
|
||||
])
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
@@ -41,6 +41,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# BUILD dependency baseline - updated Tue Jan 20 21:22:24 PST 2026
|
||||
# Do not increase these numbers - only decrease!
|
||||
library_proto_deps=26
|
||||
+27
-80
@@ -6,16 +6,12 @@
|
||||
# ./scripts/check_build_deps.sh # Check all rules
|
||||
# ./scripts/check_build_deps.sh --ci # CI mode (fail on any violation)
|
||||
# ./scripts/check_build_deps.sh --count # Just count current violations (for tracking progress)
|
||||
# ./scripts/check_build_deps.sh --strict # Fail if proto deps in library/ exceed baseline
|
||||
#
|
||||
# The baseline for proto deps is stored in scripts/build_deps_baseline.txt
|
||||
# Update it with: ./scripts/check_build_deps.sh --update-baseline
|
||||
# ./scripts/check_build_deps.sh --strict # Same as --ci (strict enforcement)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BASELINE_FILE="$SCRIPT_DIR/build_deps_baseline.txt"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
RED='\033[0;31m'
|
||||
@@ -26,15 +22,6 @@ NC='\033[0m' # No Color
|
||||
MODE="${1:-check}"
|
||||
EXIT_CODE=0
|
||||
|
||||
# Get baseline proto count (default to a high number if no baseline exists)
|
||||
get_baseline() {
|
||||
if [ -f "$BASELINE_FILE" ]; then
|
||||
grep "^library_proto_deps=" "$BASELINE_FILE" | cut -d= -f2
|
||||
else
|
||||
echo "999999" # No baseline = don't fail
|
||||
fi
|
||||
}
|
||||
|
||||
# Rule 1: src/main should not depend on src/test
|
||||
check_main_depends_on_test() {
|
||||
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
|
||||
@@ -51,26 +38,24 @@ check_main_depends_on_test() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Rule 2: library/ should not depend on src/main/protobuf
|
||||
# Note: This rule is aspirational - there are currently many violations as part of ongoing deproto effort
|
||||
check_library_depends_on_proto() {
|
||||
echo -e "${YELLOW}Checking: library/ should not depend on src/main/protobuf...${NC}"
|
||||
# Rule 2: library/ should not depend on Scala proto types
|
||||
# C++/Go proto deps are allowed (they're build-time deps for map generation tools)
|
||||
check_library_depends_on_scala_proto() {
|
||||
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
|
||||
|
||||
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null || true)
|
||||
count=$(echo "$violations" | grep -c "^//" || echo "0")
|
||||
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
|
||||
if [ -z "$violations" ]; then
|
||||
count=0
|
||||
else
|
||||
count=$(echo "$violations" | grep -c "^//" || true)
|
||||
fi
|
||||
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo -e "${YELLOW}Found $count proto dependencies in library/ (deproto in progress)${NC}"
|
||||
if [ "$MODE" == "--count" ]; then
|
||||
echo "$violations" | head -20
|
||||
if [ "$count" -gt 20 ]; then
|
||||
echo "... and $((count - 20)) more"
|
||||
fi
|
||||
fi
|
||||
# Don't fail on this rule yet - it's aspirational
|
||||
return 0
|
||||
echo -e "${RED}VIOLATION: Found $count Scala proto dependencies in library/:${NC}"
|
||||
echo "$violations"
|
||||
return 1
|
||||
else
|
||||
echo -e "${GREEN}✓ No proto dependencies in library/${NC}"
|
||||
echo -e "${GREEN}✓ No Scala proto dependencies in library/${NC}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
@@ -96,51 +81,21 @@ check_library_depends_on_proto_converters() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Count proto deps for tracking deproto progress
|
||||
# Count proto deps for informational purposes
|
||||
count_proto_deps() {
|
||||
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
|
||||
|
||||
library_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep -c "^//" || echo "0")
|
||||
echo "library/: $library_count proto dependencies"
|
||||
|
||||
baseline=$(get_baseline)
|
||||
if [ "$baseline" != "999999" ]; then
|
||||
echo "baseline: $baseline"
|
||||
if [ "$library_count" -lt "$baseline" ]; then
|
||||
echo -e "${GREEN}↓ Reduced by $((baseline - library_count)) from baseline${NC}"
|
||||
elif [ "$library_count" -gt "$baseline" ]; then
|
||||
echo -e "${RED}↑ Increased by $((library_count - baseline)) from baseline${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if proto deps exceed baseline (for strict mode)
|
||||
check_proto_baseline() {
|
||||
library_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep -c "^//" || echo "0")
|
||||
baseline=$(get_baseline)
|
||||
|
||||
echo -e "${YELLOW}Checking: proto deps in library/ should not exceed baseline...${NC}"
|
||||
echo "Current: $library_count, Baseline: $baseline"
|
||||
|
||||
if [ "$library_count" -gt "$baseline" ]; then
|
||||
echo -e "${RED}VIOLATION: Proto dependencies increased from $baseline to $library_count${NC}"
|
||||
echo "Run './scripts/check_build_deps.sh --count' to see which protos are used"
|
||||
return 1
|
||||
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
|
||||
if [ -z "$scala_proto_results" ]; then
|
||||
scala_proto_count=0
|
||||
else
|
||||
echo -e "${GREEN}✓ Proto deps at or below baseline${NC}"
|
||||
return 0
|
||||
scala_proto_count=$(echo "$scala_proto_results" | wc -l | tr -d ' ')
|
||||
fi
|
||||
}
|
||||
echo "library/ Scala proto deps: $scala_proto_count"
|
||||
|
||||
# Update baseline file
|
||||
update_baseline() {
|
||||
library_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep -c "^//" || echo "0")
|
||||
|
||||
echo "# BUILD dependency baseline - updated $(date)" > "$BASELINE_FILE"
|
||||
echo "# Do not increase these numbers - only decrease!" >> "$BASELINE_FILE"
|
||||
echo "library_proto_deps=$library_count" >> "$BASELINE_FILE"
|
||||
|
||||
echo -e "${GREEN}Updated baseline: library_proto_deps=$library_count${NC}"
|
||||
# C++/Go proto deps are expected (map generation tools)
|
||||
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
|
||||
}
|
||||
|
||||
echo "=== BUILD.bazel Dependency Check ==="
|
||||
@@ -150,22 +105,14 @@ case "$MODE" in
|
||||
--count)
|
||||
count_proto_deps
|
||||
;;
|
||||
--ci)
|
||||
--ci|--strict)
|
||||
check_main_depends_on_test || EXIT_CODE=1
|
||||
check_library_depends_on_proto || EXIT_CODE=1
|
||||
check_library_depends_on_scala_proto || EXIT_CODE=1
|
||||
check_library_depends_on_proto_converters || EXIT_CODE=1
|
||||
;;
|
||||
--strict)
|
||||
check_main_depends_on_test || EXIT_CODE=1
|
||||
check_proto_baseline || EXIT_CODE=1
|
||||
check_library_depends_on_proto_converters || EXIT_CODE=1
|
||||
;;
|
||||
--update-baseline)
|
||||
update_baseline
|
||||
;;
|
||||
*)
|
||||
check_main_depends_on_test || EXIT_CODE=1
|
||||
check_library_depends_on_proto
|
||||
check_library_depends_on_scala_proto || EXIT_CODE=1
|
||||
check_library_depends_on_proto_converters || EXIT_CODE=1
|
||||
echo ""
|
||||
count_proto_deps
|
||||
|
||||
@@ -35,6 +35,7 @@ WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
|
||||
SAVES_DIR="${APP_DIR}/saves"
|
||||
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
|
||||
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
|
||||
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -298,6 +299,10 @@ main() {
|
||||
create_flush_marker "${deploy_id}" "eagle-${staging}"
|
||||
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
|
||||
|
||||
# Write active instance file for eagle-exec helper
|
||||
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
|
||||
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
|
||||
|
||||
# Update .env for admin service
|
||||
local env_file="${APP_DIR}/.env"
|
||||
if [ "$staging" = "green" ]; then
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Helper to run docker exec against the active Eagle instance.
|
||||
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
|
||||
#
|
||||
# Usage:
|
||||
# eagle-exec printenv GEMINI_API_KEY
|
||||
# eagle-exec jcmd 1 VM.flags
|
||||
# eagle-exec sh # Get a shell
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
ACTIVE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Read active instance from file, with fallback
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
ACTIVE=$(cat "$ACTIVE_FILE")
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-green"
|
||||
else
|
||||
ACTIVE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ACTIVE" ]; then
|
||||
echo "Error: No active Eagle instance found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Active instance: $ACTIVE"
|
||||
echo "Usage: $0 <command> [args...]"
|
||||
echo "Example: $0 printenv GEMINI_API_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec docker exec "$ACTIVE" "$@"
|
||||
+61
@@ -296,6 +296,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
// Refresh stored account buttons
|
||||
RefreshStoredAccountButtons();
|
||||
|
||||
// Show sign-in tutorial for new users who have no stored accounts
|
||||
var accounts = OAuthManager.Instance?.GetStoredAccounts();
|
||||
if (accounts == null || accounts.Count == 0) {
|
||||
TutorialManager.Instance?.OnGameEvent("auth_panel_shown");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshStoredAccountButtons() {
|
||||
@@ -718,9 +724,64 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
// Clear loading indicator now that lobby is populated
|
||||
if (lobbyStatusText != null) { lobbyStatusText.text = ""; }
|
||||
|
||||
// First-run experience: check if this is truly the first time
|
||||
// If user has no running games and has never completed onboarding,
|
||||
// auto-create a 1-player game for them
|
||||
if (!_hasAutoCreatedFirstGame && ShouldAutoCreateFirstGame(lobbyResponse)) {
|
||||
_hasAutoCreatedFirstGame = true;
|
||||
Debug.Log(
|
||||
"[ConnectionHandler] First launch detected - auto-creating 1-player game");
|
||||
AutoCreateFirstGame();
|
||||
} else {
|
||||
// Trigger lobby tutorial for users who haven't seen it
|
||||
TutorialManager.Instance?.OnGameEvent("lobby_entered");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track whether we've already auto-created a first game this session
|
||||
private bool _hasAutoCreatedFirstGame = false;
|
||||
|
||||
/// <summary>
|
||||
/// Check if we should auto-create a first game for a new user.
|
||||
/// Returns true if: no running games, and onboarding not completed.
|
||||
/// </summary>
|
||||
private bool ShouldAutoCreateFirstGame(LobbyResponse lobbyResponse) {
|
||||
// Check if user has any running games
|
||||
if (lobbyResponse.RunningGames.Count > 0) return false;
|
||||
|
||||
// Check if tutorial system indicates this is a first-time user
|
||||
var tutorialManager = TutorialManager.Instance;
|
||||
if (tutorialManager == null) return false;
|
||||
|
||||
// If onboarding is complete, they've played before
|
||||
if (tutorialManager.State.OnboardingCompleted) return false;
|
||||
|
||||
// Check if they've already seen the lobby tutorial
|
||||
if (tutorialManager.State.HasCompletedTutorial("lobby_intro")) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-create and launch a 1-player game for first-time users.
|
||||
/// </summary>
|
||||
private void AutoCreateFirstGame() {
|
||||
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
|
||||
Debug.LogWarning("[ConnectionHandler] Cannot auto-create game - no leaders available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick a random leader
|
||||
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
|
||||
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
|
||||
|
||||
// Create a 1-player, 1-human game (solo vs AI)
|
||||
Debug.Log($"[ConnectionHandler] Auto-creating 1-player game with leader {leaderTextId}");
|
||||
CreateGame(leaderTextId, 1, 1);
|
||||
}
|
||||
|
||||
private void StartListeningForLobbyUpdates() {
|
||||
lock (pendingReplyLock) {
|
||||
_persistentClientConnection.SetLobbySubscriber(this);
|
||||
|
||||
+3
-1
@@ -79,9 +79,11 @@ public class CreateGameItem : MonoBehaviour {
|
||||
totalPlayersDropdown.ClearOptions();
|
||||
totalPlayersDropdown.AddOptions(
|
||||
Enumerable.Range(1, maxPlayerCount).Select(i => i.ToString()).ToList());
|
||||
// Default to 7 total players (index 6 = 7 players, so 1 human + 6 AI)
|
||||
totalPlayersDropdown.value = Math.Min(6, maxPlayerCount - 1);
|
||||
|
||||
SetHumanPlayersDropdown();
|
||||
totalHumansDropdown.value = Math.Min(totalPlayersDropdown.value, 1);
|
||||
// Default to 1 human (index 0)
|
||||
totalHumansDropdown.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,9 @@ namespace eagle {
|
||||
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
|
||||
|
||||
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
|
||||
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
|
||||
|| sgm.GameStatus.State == GameStatus.Types.State.SetUp;
|
||||
sgm.GameStatus != null &&
|
||||
(sgm.GameStatus.State == GameStatus.Types.State.GameRunning ||
|
||||
sgm.GameStatus.State == GameStatus.Types.State.SetUp);
|
||||
Dictionary<String, ShardokGameModel> RunningShardokGameModels =>
|
||||
ShardokGameModels.Where(kv => ShardokGameModelIsRunning(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
@@ -340,6 +341,9 @@ namespace eagle {
|
||||
// can see the final battle results before the models are removed.
|
||||
var endedGames = new List<ShardokGameId>();
|
||||
|
||||
// Track if we created new models that require re-subscription
|
||||
bool createdNewModels = false;
|
||||
|
||||
foreach (var oneResponse in specResponse) {
|
||||
if (!_currentModel.ShardokGameModels.TryGetValue(
|
||||
oneResponse.ShardokGameId,
|
||||
@@ -356,6 +360,12 @@ namespace eagle {
|
||||
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
|
||||
continue;
|
||||
}
|
||||
|
||||
// New model created - we need to re-subscribe so the server
|
||||
// knows to keep sending updates for this game
|
||||
createdNewModels = true;
|
||||
_connectionLogger.LogLine(
|
||||
$"[SHARDOK] Created new model for {oneResponse.ShardokGameId}, will re-subscribe");
|
||||
}
|
||||
|
||||
var updatesOk = shardokGameModel.HandleUpdates(
|
||||
@@ -387,6 +397,15 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
// If we created new models, re-subscribe so the server knows to keep
|
||||
// sending updates for these games. The original subscription didn't
|
||||
// include them because ShardokGameModels was empty at subscribe time.
|
||||
if (createdNewModels) {
|
||||
_connectionLogger.LogLine(
|
||||
"[SHARDOK] Re-subscribing to include newly created Shardok models");
|
||||
_ = StartListeningForUpdates();
|
||||
}
|
||||
|
||||
// Invoke UI callback BEFORE removing ended games, so the UI can
|
||||
// see the final battle results (with GameStatus = Victory/Defeat)
|
||||
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
|
||||
|
||||
+10
-9
@@ -511,13 +511,13 @@ namespace eagle {
|
||||
lock (this) { _pendingCommands.Add(nextCommand); }
|
||||
}
|
||||
} else {
|
||||
// CurrentEagleToken is null - server hasn't sent us
|
||||
// commands yet, or we skipped them. Retry the pending
|
||||
// command anyway; if the server already processed it, it
|
||||
// will be ignored as duplicate.
|
||||
// CurrentEagleToken is null - we're in a battle or haven't
|
||||
// received commands yet. Keep the command pending without
|
||||
// retrying to avoid flooding the server with BAD_TOKEN
|
||||
// errors.
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current token, retrying pending command with token {providedToken}");
|
||||
await PostRequest(nextCommand);
|
||||
$"No current token, keeping pending command with token {providedToken} for later");
|
||||
lock (this) { _pendingCommands.Add(nextCommand); }
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -533,10 +533,11 @@ namespace eagle {
|
||||
shardokToken);
|
||||
await PostRequest(nextCommand);
|
||||
} else if (currentShardokToken == null) {
|
||||
// No current token - retry anyway
|
||||
// No current token - keep pending without retrying to avoid
|
||||
// flooding the server with BAD_TOKEN errors.
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current Shardok token, retrying pending command with token {providedShardokToken}");
|
||||
await PostRequest(nextCommand);
|
||||
$"No current Shardok token, keeping pending command with token {providedShardokToken} for later");
|
||||
lock (this) { _pendingCommands.Add(nextCommand); }
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok token mismatch: pending={providedShardokToken} " +
|
||||
|
||||
+52
-2
@@ -33,6 +33,9 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
// Register Shardok (tactical battle) tutorials
|
||||
RegisterShardokTutorials(registry);
|
||||
|
||||
// Register lobby tutorials (pre-game)
|
||||
RegisterLobbyTutorials(registry);
|
||||
}
|
||||
|
||||
// ========== ONBOARDING SEQUENCE ==========
|
||||
@@ -304,7 +307,7 @@ namespace Eagle0.Tutorial {
|
||||
"command_march",
|
||||
"March",
|
||||
"March moves your army to another province.\n\n" +
|
||||
"• Select a <b>destination</b> province\n" +
|
||||
"• <b>Right-click a province</b> on the map, or select from the <b>dropdown menu</b>\n" +
|
||||
"• Choose which <b>heroes and battalions</b> to take\n" +
|
||||
"• Optionally bring <b>supplies</b> (food and gold)\n\n" +
|
||||
"If you march into enemy territory, battle may ensue!");
|
||||
@@ -861,7 +864,7 @@ namespace Eagle0.Tutorial {
|
||||
"shardok_unit_placement",
|
||||
"Place Your Units",
|
||||
"Before battle begins, position your army on the battlefield.\n\n" +
|
||||
"Click a unit in the <b>Unplaced Units</b> panel on the right, then click a <b>highlighted hex</b> to place it.\n\n" +
|
||||
"Your units are placed in starting positions. <b>Click a unit</b> to select it, then <b>click another highlighted hex</b> to move it.\n\n" +
|
||||
"Right-click a placed unit to return it to reserves. When ready, click <b>Commit</b>.",
|
||||
TutorialDisplayMode.Modal);
|
||||
unitPlacement.RequiredCompletedTutorials =
|
||||
@@ -935,5 +938,52 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
// ========== PRE-GAME TUTORIALS ==========
|
||||
// These appear before the user enters a game (sign-in, lobby).
|
||||
|
||||
private static void RegisterLobbyTutorials(TutorialTriggerRegistry registry) {
|
||||
// Sign-in tutorial - shown first time user sees the auth panel
|
||||
var signIn = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
signIn.SequenceId = "sign_in";
|
||||
signIn.DisplayName = "Sign In";
|
||||
signIn.IsOnboarding = false;
|
||||
|
||||
signIn.Steps = new List<TutorialStep> { new TutorialStep {
|
||||
StepId = "sign_in_step",
|
||||
Title = "Sign In to Play",
|
||||
Description =
|
||||
"Welcome to <b>Eagle0</b>!\n\n" +
|
||||
"Sign in using the <b>same account</b> you used to create your display name on the website.\n\n" +
|
||||
"Click one of the sign-in buttons below to continue.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
} };
|
||||
|
||||
registry.RegisterTutorial(signIn, "auth_panel_shown");
|
||||
|
||||
// Lobby introduction - shown first time user enters lobby
|
||||
var lobbyIntro = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
lobbyIntro.SequenceId = "lobby_intro";
|
||||
lobbyIntro.DisplayName = "Welcome to the Lobby";
|
||||
lobbyIntro.IsOnboarding = false;
|
||||
|
||||
lobbyIntro.Steps = new List<TutorialStep> { new TutorialStep {
|
||||
StepId = "lobby_intro_step",
|
||||
Title = "Welcome to Eagle0!",
|
||||
Description =
|
||||
"This is the game lobby. From here you can:\n\n" +
|
||||
"• <b>Create a new game</b> to start a fresh campaign\n" +
|
||||
"• <b>Join</b> a game waiting for players\n" +
|
||||
"• <b>Continue</b> your existing games\n\n" +
|
||||
"To get started, create a <b>1-player game</b> to learn the basics against AI opponents.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
} };
|
||||
|
||||
registry.RegisterTutorial(lobbyIntro, "lobby_entered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<Properties StartupConfiguration="{4CE96269-4FA5-4F9D-BFBD-7440BA119166}|Default">
|
||||
<MonoDevelop.Ide.ItemProperties.EagleInstaller PreferredExecutionTarget="MonoDevelop.Default" />
|
||||
<MonoDevelop.Ide.Workbench ActiveDocument="EagleInstaller/EagleUpdater.cs">
|
||||
<Files>
|
||||
<File FileName="EagleInstaller/EagleUpdater.cs" Line="18" Column="60" />
|
||||
<File FileName="EagleInstaller/Program.cs" Line="1" Column="14" />
|
||||
</Files>
|
||||
<Pads>
|
||||
<Pad Id="ProjectPad">
|
||||
<State name="__root__">
|
||||
<Node name="EagleInstaller" expanded="True">
|
||||
<Node name="EagleInstaller" expanded="True">
|
||||
<Node name="EagleUpdater.cs" selected="True" />
|
||||
</Node>
|
||||
</Node>
|
||||
</State>
|
||||
</Pad>
|
||||
<Pad Id="MonoDevelop.Debugger.WatchPad">
|
||||
<State />
|
||||
</Pad>
|
||||
</Pads>
|
||||
</MonoDevelop.Ide.Workbench>
|
||||
<MonoDevelop.Ide.DebuggingService.PinnedWatches>
|
||||
<Watch file="../../../../../unity/eagle0/Assets/Eagle/RemoteEagleClient.cs" line="37" column="0" endLine="0" endColumn="0" offsetX="-1" offsetY="-1" expression="grpcClient" liveUpdate="False" />
|
||||
<Watch file="../../../../../unity/eagle0/Assets/Eagle/RemoteEagleClient.cs" line="37" column="0" endLine="0" endColumn="0" offsetX="-1" offsetY="-1" expression="grpcClient" liveUpdate="False" />
|
||||
<Watch file="../../../../../unity/eagle0/Assets/Eagle/CommandSelectors/MarchCommandSelector.cs" line="89" column="0" endLine="0" endColumn="0" offsetX="-1" offsetY="-1" expression="SelectedOneProvinceCommand" liveUpdate="False" />
|
||||
</MonoDevelop.Ide.DebuggingService.PinnedWatches>
|
||||
<MonoDevelop.Ide.Workspace ActiveConfiguration="Debug" />
|
||||
<MonoDevelop.Ide.DebuggingService.Breakpoints>
|
||||
<BreakpointStore />
|
||||
</MonoDevelop.Ide.DebuggingService.Breakpoints>
|
||||
<MultiItemStartupConfigurations />
|
||||
</Properties>
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -1,147 +0,0 @@
|
||||
# Eagle0 Installer Credential System
|
||||
|
||||
The installer has been updated to use dynamic user credentials instead of hardcoded authentication. This improves security and provides a better user experience.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Dynamic Credential Management**
|
||||
- **No hardcoded passwords** in configuration files
|
||||
- **User-prompted login** when credentials are needed
|
||||
- **Secure credential storage** using Windows Data Protection API
|
||||
- **Automatic credential sharing** with Unity client
|
||||
|
||||
### 2. **User Experience**
|
||||
- **Saved credentials** are reused automatically
|
||||
- **Login dialog** appears only when needed
|
||||
- **Remember credentials** option for convenience
|
||||
- **401 error handling** with automatic re-prompting
|
||||
|
||||
### 3. **Security Features**
|
||||
- **Encrypted password storage** using DPAPI (user-scoped)
|
||||
- **Registry storage** for username/server (non-sensitive)
|
||||
- **Automatic credential clearing** on authentication failure
|
||||
- **Unity client compatibility** for shared login state
|
||||
|
||||
## How It Works
|
||||
|
||||
### **First Run Flow**
|
||||
1. User launches installer
|
||||
2. No saved credentials found
|
||||
3. Login dialog appears
|
||||
4. User enters username/password/server
|
||||
5. Credentials saved securely (if requested)
|
||||
6. Installer proceeds with updates
|
||||
|
||||
### **Subsequent Runs**
|
||||
1. User launches installer
|
||||
2. Saved credentials loaded automatically
|
||||
3. Installer proceeds with updates
|
||||
4. No user interaction needed
|
||||
|
||||
### **Authentication Failure Flow**
|
||||
1. Installer detects 401 Unauthorized response
|
||||
2. Saved credentials cleared automatically
|
||||
3. Login dialog appears with error message
|
||||
4. User enters corrected credentials
|
||||
5. Installer retries with new credentials
|
||||
|
||||
### **Changing Valid Credentials**
|
||||
Users can change working credentials through multiple methods:
|
||||
|
||||
**Method 1: Interactive Prompt (Default)**
|
||||
1. Launch installer normally
|
||||
2. See message: "Using saved credentials for user: john@example.com"
|
||||
3. Press 'C' within 3 seconds to change credentials
|
||||
4. Login dialog appears with current credentials pre-filled
|
||||
|
||||
**Method 2: Command Line Flag**
|
||||
```bash
|
||||
EagleInstaller.exe --login
|
||||
EagleInstaller.exe --change-credentials
|
||||
```
|
||||
|
||||
**Method 3: Clear and Re-enter**
|
||||
```bash
|
||||
EagleInstaller.exe --clear-credentials
|
||||
EagleInstaller.exe # Next run will prompt for new credentials
|
||||
```
|
||||
|
||||
### **Credential Sharing with Unity Client**
|
||||
- Launcher saves credentials in Unity PlayerPrefs format
|
||||
- Unity client can access same username/password/server
|
||||
- Seamless transition from launcher to game
|
||||
|
||||
## File Structure
|
||||
|
||||
### **New Files Added**
|
||||
- `CredentialManager.cs` - Secure credential storage/retrieval
|
||||
- `LoginDialog.cs` - Windows Forms login dialog
|
||||
|
||||
### **Modified Files**
|
||||
- `Program.cs` - Integrated credential prompting and error handling
|
||||
- `EagleUpdater.cs` - Dynamic authentication and 401 error detection
|
||||
- `EagleInstaller.csproj` - Added Windows Forms support
|
||||
- `configuration.txt` - Removed hardcoded credentials
|
||||
|
||||
### **Credential Storage Locations**
|
||||
- **Username/Server**: `HKCU\SOFTWARE\Eagle0\Launcher`
|
||||
- **Password**: `%LocalAppData%\eagle0\eagle0_credentials.dat` (encrypted)
|
||||
- **Unity Sharing**: `HKCU\SOFTWARE\Unity\UnityEditor\CompanyName\ProductName`
|
||||
|
||||
## Configuration
|
||||
|
||||
The `configuration.txt` file now only contains non-sensitive settings:
|
||||
|
||||
```
|
||||
manifest_name = eagle0WIN_manifest.txt
|
||||
remote_manifest_prefix = assets/unity3d/
|
||||
remote_asset_prefix = assets/unity3d/win/
|
||||
```
|
||||
|
||||
Server URL, username, and password are now provided by the user through the login dialog.
|
||||
|
||||
## Command Line Options
|
||||
|
||||
The installer supports several command line flags for credential management:
|
||||
|
||||
```bash
|
||||
EagleInstaller.exe # Normal operation
|
||||
EagleInstaller.exe --help # Show help message
|
||||
EagleInstaller.exe --login # Force credential dialog
|
||||
EagleInstaller.exe --change-credentials # Same as --login
|
||||
EagleInstaller.exe --clear-credentials # Clear saved credentials
|
||||
```
|
||||
|
||||
During normal operation, users see a 3-second prompt to change credentials:
|
||||
```
|
||||
Using saved credentials for user: john@example.com
|
||||
Press 'C' within 3 seconds to change credentials, or any other key to continue...
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Security**: No more plaintext passwords in config files
|
||||
2. **Flexibility**: Users can connect to different servers/accounts
|
||||
3. **User Experience**: Credentials remembered between sessions
|
||||
4. **Easy Changes**: Multiple ways to update credentials
|
||||
5. **Error Recovery**: Automatic handling of expired/invalid credentials
|
||||
6. **Integration**: Shared credentials with Unity game client
|
||||
7. **Self-Updating**: Maintains credential system through installer updates
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### **Two-Stage Approach**
|
||||
The installer maintains both the two-stage installer update system AND the new credential system:
|
||||
|
||||
**Stage 1**: Check installer version (with authentication)
|
||||
**Stage 2**: Update game files (with same credentials)
|
||||
|
||||
Both stages use the same credential system and handle 401 errors consistently.
|
||||
|
||||
### **Error Handling**
|
||||
- `AuthenticationFailedException` for 401 responses
|
||||
- Automatic credential retry loop
|
||||
- Graceful fallback on credential prompt cancellation
|
||||
- Backup/restore for failed credential saves
|
||||
|
||||
This system provides enterprise-grade credential management while maintaining the simple user experience of the original installer.
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.29613.14
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EagleInstaller", "EagleInstaller\EagleInstaller.csproj", "{4CE96269-4FA5-4F9D-BFBD-7440BA119166}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4CE96269-4FA5-4F9D-BFBD-7440BA119166}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {359E9771-2100-4864-B42B-14FA1A96A967}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,3 +0,0 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8a221793e4b13ae450dccb6e74f6985a273b27d67dca5c4bc2c2e56df33bfe7_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileStream_002EWindows_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ffc8f9bd3c0ac5ab6b9dabf421c55f1afeedd8abe1dfc288502292a552fb8d_003FFileStream_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
|
||||
@@ -1 +0,0 @@
|
||||
obj/
|
||||
@@ -1,16 +0,0 @@
|
||||
# load("@io_bazel_rules_dotnet//dotnet:defs.bzl", "core_binary")
|
||||
|
||||
#core_binary(
|
||||
# name = "EagleInstaller.exe",
|
||||
# srcs = [
|
||||
# "EagleUpdater.cs",
|
||||
# "Program.cs",
|
||||
# ],
|
||||
# deps = [
|
||||
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.linq.dll",
|
||||
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.net.dll",
|
||||
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.console.dll",
|
||||
# "@io_bazel_rules_dotnet//dotnet/stdlib.core:system.diagnostics.process.dll",
|
||||
# ],
|
||||
# visibility = ["//visibility:public"],
|
||||
#)
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace EagleInstaller {
|
||||
public class CredentialManager {
|
||||
public const string ServerUrl = "https://assets.eagle0.net";
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon>eagle0.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Ed25519 signature verification for manifest -->
|
||||
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="AWSSDK.S3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="configuration.txt" />
|
||||
<EmbeddedResource Include="configuration.txt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<_LastSelectedProfileId>C:\Users\dan\OneDrive\Documents\GitHub\eagle0\clients\windows\EagleInstaller\EagleInstaller\Properties\PublishProfiles\FolderProfile.pubxml</_LastSelectedProfileId>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,583 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace EagleInstaller {
|
||||
internal class UpdaterFailureException
|
||||
(string message) : Exception(message);
|
||||
|
||||
internal class ManifestSecurityException
|
||||
(string message) : Exception(message);
|
||||
|
||||
public class InstallerUpdateInfo {
|
||||
public bool InstallerNeedsUpdate { get; set; }
|
||||
public string NewInstallerVersion { get; set; }
|
||||
public string NewInstallerUrl { get; set; }
|
||||
}
|
||||
|
||||
class EagleUpdater {
|
||||
private static readonly string CurrentInstallerVersion = GetCurrentInstallerSHA();
|
||||
|
||||
private static readonly Dictionary<string, string> ConfigFileEntries = ReadConfiguration();
|
||||
private static readonly string ManifestName = ConfigFileEntries["manifest_name"];
|
||||
private static readonly string RemoteManifestName =
|
||||
ConfigFileEntries["remote_manifest_prefix"] + ManifestName;
|
||||
private static readonly string RemotePrefix = ConfigFileEntries["remote_asset_prefix"];
|
||||
|
||||
private const int DownloadSlots = 8;
|
||||
private const int RetryCount = 3;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private static readonly SemaphoreSlim Pool = new(DownloadSlots, DownloadSlots);
|
||||
|
||||
private Uri ServiceUrl { get; set; }
|
||||
|
||||
private struct FetchAttempt {
|
||||
public bool Success;
|
||||
public KeyValuePair<string, string> Kvp;
|
||||
}
|
||||
|
||||
private static string GetCurrentInstallerSHA() {
|
||||
try {
|
||||
string currentPath = Environment.ProcessPath ??
|
||||
Path.Combine(AppContext.BaseDirectory, "EagleInstaller.exe");
|
||||
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
using var stream = File.OpenRead(currentPath);
|
||||
var hashBytes = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
|
||||
} catch (Exception) {
|
||||
// If we can't calculate our own SHA, use a fallback
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadConfiguration() {
|
||||
var assembly = System.Reflection.Assembly.GetExecutingAssembly();
|
||||
using var stream =
|
||||
assembly.GetManifestResourceStream("EagleInstaller.configuration.txt");
|
||||
using var reader = new StreamReader(stream);
|
||||
var configText = reader.ReadToEnd();
|
||||
|
||||
if (string.IsNullOrEmpty(configText)) { return new Dictionary<string, string>(); }
|
||||
|
||||
var dict = new Dictionary<string, string>();
|
||||
using (StringReader sr = new StringReader(configText)) {
|
||||
string line;
|
||||
while ((line = sr.ReadLine()) != null) {
|
||||
// Skip empty lines and comments
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
var idx = line.IndexOf(" = ");
|
||||
if (idx > 0) {
|
||||
var key = line.Substring(0, idx);
|
||||
var value = line.Substring(idx + 3);
|
||||
dict[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
// Ed25519 public key for manifest signature verification (base64 encoded)
|
||||
// Generated with: go run scripts/generate_manifest_keys.go
|
||||
// This key should be updated when a new key pair is generated
|
||||
private static readonly string ManifestPublicKeyB64 =
|
||||
ConfigFileEntries.TryGetValue("manifest_public_key", out var key) ? key : null;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Ed25519 signature on a manifest.
|
||||
/// Returns the manifest content (without signature line) if valid.
|
||||
/// Throws ManifestSecurityException if signature is missing, invalid, or cannot be
|
||||
/// verified.
|
||||
/// </summary>
|
||||
private static string VerifyManifestSignature(string manifestText) {
|
||||
if (string.IsNullOrEmpty(manifestText)) {
|
||||
throw new ManifestSecurityException("Manifest is empty");
|
||||
}
|
||||
|
||||
// Require public key to be configured
|
||||
if (string.IsNullOrEmpty(ManifestPublicKeyB64)) {
|
||||
Logger.WriteError(
|
||||
"SECURITY ERROR: No public key configured for manifest verification");
|
||||
throw new ManifestSecurityException(
|
||||
"No public key configured for manifest verification");
|
||||
}
|
||||
|
||||
// Check for signature line at start: # signature=<base64>
|
||||
var lines = manifestText.Split('\n');
|
||||
if (lines.Length == 0 || !lines[0].TrimStart().StartsWith("# signature=")) {
|
||||
Logger.WriteError("SECURITY ERROR: Manifest is not signed");
|
||||
throw new ManifestSecurityException("Manifest is not signed");
|
||||
}
|
||||
|
||||
// Extract signature
|
||||
string signatureLine = lines[0];
|
||||
string signatureB64 = signatureLine.Substring(signatureLine.IndexOf('=') + 1).Trim();
|
||||
|
||||
// Content is everything after the signature line
|
||||
string content = string.Join('\n', lines.Skip(1));
|
||||
|
||||
try {
|
||||
byte[] signatureBytes = Convert.FromBase64String(signatureB64);
|
||||
byte[] publicKeyBytes = Convert.FromBase64String(ManifestPublicKeyB64);
|
||||
byte[] contentBytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
|
||||
// Verify using Ed25519
|
||||
bool valid = VerifyEd25519Signature(publicKeyBytes, contentBytes, signatureBytes);
|
||||
|
||||
if (valid) {
|
||||
Logger.WriteLine("Manifest signature verified successfully");
|
||||
return content;
|
||||
} else {
|
||||
Logger.WriteError("SECURITY ERROR: Manifest signature is INVALID!");
|
||||
Logger.WriteError("This could indicate a compromised or tampered manifest.");
|
||||
throw new ManifestSecurityException("Manifest signature verification failed");
|
||||
}
|
||||
} catch (ManifestSecurityException) {
|
||||
throw; // Re-throw security exceptions
|
||||
} catch (Exception e) {
|
||||
Logger.WriteError(
|
||||
$"SECURITY ERROR: Failed to verify manifest signature: {e.Message}");
|
||||
throw new ManifestSecurityException(
|
||||
$"Manifest signature verification error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an Ed25519 signature using the NSec library.
|
||||
/// </summary>
|
||||
private static bool
|
||||
VerifyEd25519Signature(byte[] publicKeyBytes, byte[] data, byte[] signature) {
|
||||
try {
|
||||
var algorithm = NSec.Cryptography.SignatureAlgorithm.Ed25519;
|
||||
var publicKey = NSec.Cryptography.PublicKey.Import(
|
||||
algorithm,
|
||||
publicKeyBytes,
|
||||
NSec.Cryptography.KeyBlobFormat.RawPublicKey);
|
||||
return algorithm.Verify(publicKey, data, signature);
|
||||
} catch (Exception e) {
|
||||
Logger.WriteError($"Ed25519 verification error: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public EagleUpdater(string serverUrl) {
|
||||
if (string.IsNullOrEmpty(serverUrl)) {
|
||||
throw new ArgumentNullException(nameof(serverUrl));
|
||||
}
|
||||
ServiceUrl = new Uri(serverUrl);
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
private List<string> Discrepancies(
|
||||
Dictionary<string, string> desired,
|
||||
Dictionary<string, string> present) {
|
||||
return desired
|
||||
.Where(kvp => !present.ContainsKey(kvp.Key) ||
|
||||
!present[kvp.Key].Equals(kvp.Value))
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<InstallerUpdateInfo> CheckForInstallerUpdate() {
|
||||
try {
|
||||
Logger.WriteLine("Checking for installer updates...");
|
||||
string manifestText = await RemoteManifestText();
|
||||
|
||||
// Parse installer version from manifest comments
|
||||
var lines = manifestText.Split('\n');
|
||||
var versionLine =
|
||||
lines.FirstOrDefault(l => l.TrimStart().StartsWith("# installer_version="));
|
||||
var urlLine =
|
||||
lines.FirstOrDefault(l => l.TrimStart().StartsWith("# installer_url="));
|
||||
|
||||
if (versionLine != null && urlLine != null) {
|
||||
var remoteVersion = versionLine.Split('=') [1].Trim();
|
||||
var installerUrl = urlLine.Split('=') [1].Trim();
|
||||
|
||||
if (remoteVersion != CurrentInstallerVersion) {
|
||||
Logger.WriteLine(
|
||||
$"New installer version available: {remoteVersion} (current: {CurrentInstallerVersion})");
|
||||
return new InstallerUpdateInfo {
|
||||
InstallerNeedsUpdate = true,
|
||||
NewInstallerVersion = remoteVersion,
|
||||
NewInstallerUrl = installerUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Logger.WriteLine("Installer is up to date");
|
||||
return new InstallerUpdateInfo { InstallerNeedsUpdate = false };
|
||||
} catch (Exception e) {
|
||||
Logger.WriteError($"Failed to check for installer updates: {e.Message}");
|
||||
// If we can't check for updates, assume no update needed
|
||||
return new InstallerUpdateInfo { InstallerNeedsUpdate = false };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> MaybePerformUpdateWithRetries(string localPath) {
|
||||
string existingManifestPath = Path.Combine(localPath, ManifestName);
|
||||
|
||||
Logger.UpdateStatus("Loading old manifest...");
|
||||
string existingText = await LocalManifestText(existingManifestPath);
|
||||
|
||||
Logger.UpdateStatus("Fetching new manifest...");
|
||||
string newText = await RemoteManifestText();
|
||||
|
||||
if (existingText != null && existingText.Equals(newText)) {
|
||||
Logger.UpdateStatus("Files are up to date!");
|
||||
return true;
|
||||
}
|
||||
|
||||
var currentFiles = ShasFromText(existingText);
|
||||
var desiredFiles = ShasFromText(newText);
|
||||
|
||||
List<string> discrepancies = Discrepancies(desiredFiles, currentFiles);
|
||||
|
||||
for (int retry = 0; retry < RetryCount; retry++) {
|
||||
Logger.UpdateStatus($"Update attempt {retry + 1} of {RetryCount}...");
|
||||
var newFiles = await AttemptUpdate(localPath, desiredFiles, currentFiles);
|
||||
|
||||
List<string> newDiscrepancies = Discrepancies(desiredFiles, newFiles);
|
||||
if (newDiscrepancies.Count == 0) {
|
||||
Logger.UpdateStatus("Update completed successfully!");
|
||||
Logger.WriteLine("Writing downloaded manifest...");
|
||||
File.WriteAllText(existingManifestPath, newText);
|
||||
return true;
|
||||
} else {
|
||||
Logger.WriteLine("There are still some discrepancies.");
|
||||
if (newDiscrepancies.Count == discrepancies.Count) {
|
||||
Logger.WriteLine("We don't seem to be making progress.");
|
||||
} else {
|
||||
Logger.WriteLine("Some progress made. Remaining discrepancies:");
|
||||
newDiscrepancies.ForEach(s => Logger.WriteLine($" {s}"));
|
||||
discrepancies = newDiscrepancies;
|
||||
}
|
||||
|
||||
Logger.WriteLine("Writing out the current state.");
|
||||
File.WriteAllText(existingManifestPath, TextFromShas(newFiles));
|
||||
currentFiles = newFiles;
|
||||
}
|
||||
}
|
||||
|
||||
// sadness
|
||||
Logger.WriteError($"Exhausted {RetryCount} attempts. Update failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, string>> AttemptUpdate(
|
||||
string localPath,
|
||||
Dictionary<string, string> desiredFiles,
|
||||
Dictionary<string, string> existingFiles) {
|
||||
try {
|
||||
IEnumerable<KeyValuePair<string, string>> pathsToUpdate =
|
||||
from newItem in desiredFiles where(
|
||||
!existingFiles.ContainsKey(newItem.Key) ||
|
||||
!existingFiles[newItem.Key].Equals(newItem.Value)) select newItem;
|
||||
|
||||
IEnumerable<Task<FetchAttempt>> updateTasks =
|
||||
pathsToUpdate.Select(remotePathAndSha => {
|
||||
Logger.WriteLine($"Downloading: {remotePathAndSha.Key}");
|
||||
return FetchAndWriteOne(
|
||||
remotePathAndSha.Key,
|
||||
localPath,
|
||||
remotePathAndSha.Value);
|
||||
});
|
||||
|
||||
IEnumerable<string> pathsToDelete = from oldItem in existingFiles
|
||||
where!desiredFiles.ContainsKey(oldItem.Key)
|
||||
select oldItem.Key;
|
||||
|
||||
pathsToDelete.ToList().ForEach(path => {
|
||||
var localDeletePath = WritePathFromRemotePath(path, localPath);
|
||||
Logger.WriteLine($"Deleting: {localDeletePath}");
|
||||
File.Delete(localDeletePath);
|
||||
existingFiles.Remove(path);
|
||||
});
|
||||
|
||||
IEnumerable<string> pathsOk =
|
||||
from newItem in desiredFiles where(
|
||||
existingFiles.ContainsKey(newItem.Key) &&
|
||||
existingFiles[newItem.Key].Equals(newItem.Value))
|
||||
select newItem.Key;
|
||||
|
||||
pathsOk.ToList().ForEach(path => Logger.WriteLine($"Up to date: {path}"));
|
||||
|
||||
var fetchAttemptResults = await Task.WhenAll(updateTasks);
|
||||
|
||||
// Add successful downloads to the existingFiles dictionary
|
||||
fetchAttemptResults.Where(fa => fa.Success)
|
||||
.Select(fa => fa.Kvp)
|
||||
.ToList()
|
||||
.ForEach(fa => existingFiles[fa.Key] = fa.Value);
|
||||
|
||||
if (fetchAttemptResults.ToList().TrueForAll(fa => fa.Success)) {
|
||||
Logger.WriteLine("All downloads completed successfully.");
|
||||
} else {
|
||||
Logger.WriteError("Some file downloads failed!");
|
||||
}
|
||||
} catch (Exception e) { Logger.WriteError($"Unexpected exception: {e}"); }
|
||||
return existingFiles;
|
||||
}
|
||||
|
||||
static async Task<string> LocalManifestText(string existingManifestPath) {
|
||||
if (!File.Exists(existingManifestPath)) return null;
|
||||
|
||||
return await File.ReadAllTextAsync(existingManifestPath);
|
||||
}
|
||||
|
||||
async Task<string> RemoteManifestText() {
|
||||
using HttpResponseMessage response =
|
||||
await _httpClient.GetAsync(new Uri(ServiceUrl, RemoteManifestName));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader reader = new StreamReader(responseStream);
|
||||
string rawManifest = await reader.ReadToEndAsync();
|
||||
|
||||
// Verify signature and extract content (strips signature line if present)
|
||||
// Throws ManifestSecurityException if public key is configured and verification fails
|
||||
return VerifyManifestSignature(rawManifest);
|
||||
}
|
||||
|
||||
static Dictionary<String, String> ShasFromText(string text) {
|
||||
if (text == null || text.Length == 0) { return new Dictionary<string, string>(); }
|
||||
List<string> lines =
|
||||
text.Split('\n').Where(str => str.Length > 0 && str[0] != '#').ToList();
|
||||
|
||||
return lines.Select(str => str.Split(" "))
|
||||
.ToDictionary(strs => strs[1], strs => strs[0]);
|
||||
}
|
||||
|
||||
static string TextFromShas(Dictionary<string, string> shas) {
|
||||
string text = "";
|
||||
foreach (var kvp in shas) { text += $"{kvp.Value} {kvp.Key}\n"; }
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string WritePathFromRemotePath(string remotePath, string localDir) {
|
||||
var components = remotePath.Split('/');
|
||||
var writePath = localDir;
|
||||
foreach (string component in components) {
|
||||
writePath = Path.Combine(writePath, component);
|
||||
}
|
||||
|
||||
return writePath;
|
||||
}
|
||||
|
||||
async Task<FetchAttempt>
|
||||
FetchAndWriteOne(string remotePath, string localDir, string expectedSha) {
|
||||
string writePath = WritePathFromRemotePath(remotePath, localDir);
|
||||
string tempPath = writePath + ".downloading";
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(writePath));
|
||||
|
||||
await Pool.WaitAsync();
|
||||
|
||||
try {
|
||||
using HttpResponseMessage response =
|
||||
await _httpClient.GetAsync(new Uri(ServiceUrl, RemotePrefix + remotePath));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
// Stream directly to disk while computing SHA256 incrementally
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
await using (FileStream fileStream = File.Create(tempPath)) {
|
||||
var buffer = new byte[81920]; // 80KB buffer
|
||||
int bytesRead;
|
||||
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) >
|
||||
0) {
|
||||
// Write to disk
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
// Update hash incrementally
|
||||
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
|
||||
}
|
||||
}
|
||||
// Finalize hash computation
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
string computedSha =
|
||||
BitConverter.ToString(sha.Hash).Replace("-", string.Empty).ToLower();
|
||||
|
||||
if (computedSha.Equals(expectedSha)) {
|
||||
// SHA matches - rename temp file to final location
|
||||
if (File.Exists(writePath)) { File.Delete(writePath); }
|
||||
File.Move(tempPath, writePath);
|
||||
Logger.WriteLine($"✓ Verified and saved: {remotePath}");
|
||||
return new FetchAttempt {
|
||||
Success = true,
|
||||
Kvp = new KeyValuePair<String, String>(remotePath, computedSha)
|
||||
};
|
||||
} else {
|
||||
// SHA mismatch - delete temp file
|
||||
try {
|
||||
File.Delete(tempPath);
|
||||
} catch {}
|
||||
Logger.WriteError($"✗ SHA mismatch for {remotePath}");
|
||||
return new FetchAttempt() { Success = false };
|
||||
}
|
||||
} catch (TaskCanceledException e) {
|
||||
// Clean up temp file on failure
|
||||
try {
|
||||
File.Delete(tempPath);
|
||||
} catch {}
|
||||
Logger.WriteError($"Download cancelled: {e.Message}");
|
||||
return new FetchAttempt() { Success = false };
|
||||
} catch (HttpRequestException e) {
|
||||
try {
|
||||
File.Delete(tempPath);
|
||||
} catch {}
|
||||
Logger.WriteError($"Network error: {e.Message}");
|
||||
return new FetchAttempt() { Success = false };
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
File.Delete(tempPath);
|
||||
} catch {}
|
||||
Logger.WriteError($"Download failed: {e.Message}");
|
||||
return new FetchAttempt() { Success = false };
|
||||
} finally { Pool.Release(); }
|
||||
}
|
||||
|
||||
public async Task<bool> DownloadAndLaunchNewInstaller(
|
||||
string installerUrl,
|
||||
string expectedSha) {
|
||||
try {
|
||||
Logger.UpdateStatus("Downloading new installer...");
|
||||
|
||||
// Create temp directory for new installer
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "eagle0_installer_update");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
string newInstallerPath = Path.Combine(tempDir, "EagleInstaller_new.exe");
|
||||
|
||||
// Download new installer with progress tracking
|
||||
{
|
||||
using HttpResponseMessage response =
|
||||
await _httpClient.GetAsync(new Uri(ServiceUrl, installerUrl));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
long? totalBytes = response.Content.Headers.ContentLength;
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
await using FileStream fileStream = File.Create(newInstallerPath);
|
||||
|
||||
// Download with progress feedback
|
||||
var buffer = new byte[8192];
|
||||
long totalBytesRead = 0;
|
||||
int bytesRead;
|
||||
int lastProgressDots = 0;
|
||||
|
||||
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) >
|
||||
0) {
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
// Show progress as dots or percentage
|
||||
if (totalBytes.HasValue) {
|
||||
int progressPercent = (int)((totalBytesRead * 100) / totalBytes.Value);
|
||||
Logger.UpdateStatus($"Downloading new installer... {progressPercent}%");
|
||||
} else {
|
||||
// Show dots if we don't know total size
|
||||
int currentDots = (int)(totalBytesRead / 10240); // One dot per 10KB
|
||||
if (currentDots > lastProgressDots) {
|
||||
lastProgressDots = currentDots;
|
||||
string dots = new string(
|
||||
'.',
|
||||
Math.Min(currentDots % 20, 20)); // Cycle dots every 20
|
||||
Logger.UpdateStatus($"Downloading new installer{dots}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify SHA256 before launching
|
||||
Logger.UpdateStatus("Verifying installer integrity...");
|
||||
string computedSha;
|
||||
using (var sha256 = System.Security.Cryptography.SHA256.Create()) using (
|
||||
var stream = File.OpenRead(newInstallerPath)) {
|
||||
var hashBytes = sha256.ComputeHash(stream);
|
||||
computedSha =
|
||||
BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
|
||||
}
|
||||
|
||||
if (!computedSha.Equals(expectedSha, StringComparison.OrdinalIgnoreCase)) {
|
||||
Logger.WriteError($"SECURITY ERROR: Installer SHA mismatch!");
|
||||
Logger.WriteError($" Expected: {expectedSha}");
|
||||
Logger.WriteError($" Got: {computedSha}");
|
||||
// Delete the corrupted/tampered file
|
||||
try {
|
||||
File.Delete(newInstallerPath);
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
Logger.WriteLine($"✓ Installer verified: SHA256 matches");
|
||||
|
||||
// Remove Windows "Mark of the Web" Zone.Identifier to prevent SmartScreen blocking
|
||||
// When launched programmatically, Windows blocks unsigned executables downloaded
|
||||
// from the internet. Removing the Zone.Identifier alternate data stream fixes this.
|
||||
// Note: File.Exists() doesn't work with ADS, so we just try to delete
|
||||
// unconditionally.
|
||||
try {
|
||||
string zoneIdPath = newInstallerPath + ":Zone.Identifier";
|
||||
File.Delete(zoneIdPath);
|
||||
Logger.WriteLine("Removed Zone.Identifier from downloaded installer");
|
||||
} catch (Exception) {
|
||||
// Ignore errors - may not have Zone.Identifier or access denied
|
||||
}
|
||||
|
||||
// Wait a moment to ensure file handles are released
|
||||
await Task.Delay(500);
|
||||
|
||||
Logger.WriteLine($"New installer downloaded successfully");
|
||||
|
||||
// Get current installer path
|
||||
string currentInstallerPath =
|
||||
Environment.ProcessPath ??
|
||||
Path.Combine(AppContext.BaseDirectory, "EagleInstaller.exe");
|
||||
|
||||
// Launch new installer with replacement arguments
|
||||
var processInfo = new System.Diagnostics.ProcessStartInfo {
|
||||
FileName = newInstallerPath,
|
||||
Arguments = $"--replace-installer \"{currentInstallerPath}\"",
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
Logger.WriteLine($"About to launch: {newInstallerPath}");
|
||||
Logger.WriteLine($"With arguments: {processInfo.Arguments}");
|
||||
Logger.WriteLine($"File exists: {File.Exists(newInstallerPath)}");
|
||||
|
||||
Logger.UpdateStatus("Launching new installer...");
|
||||
var process = System.Diagnostics.Process.Start(processInfo);
|
||||
|
||||
if (process == null) {
|
||||
Logger.WriteError("Failed to start new installer process");
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.WriteLine($"New installer process started with ID: {process.Id}");
|
||||
|
||||
// Wait a moment to see if the process exits immediately (crash)
|
||||
await Task.Delay(500);
|
||||
if (process.HasExited) {
|
||||
Logger.WriteError(
|
||||
$"New installer process exited immediately with code: {process.ExitCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.WriteLine("New installer appears to be running successfully");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.WriteError($"Failed to download or launch new installer: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace EagleInstaller {
|
||||
public static class Logger {
|
||||
private static MainForm _mainForm;
|
||||
|
||||
public static void Initialize(MainForm mainForm) { _mainForm = mainForm; }
|
||||
|
||||
public static void WriteLine(string message) {
|
||||
// Write to console (for when running from command line)
|
||||
Console.WriteLine(message);
|
||||
|
||||
// Also write to GUI if available
|
||||
if (_mainForm != null) { _mainForm.AppendProgress(message); }
|
||||
}
|
||||
|
||||
public static void UpdateStatus(string status) {
|
||||
// Write to console
|
||||
Console.WriteLine($"STATUS: {status}");
|
||||
|
||||
// Update GUI status if available
|
||||
if (_mainForm != null) { _mainForm.UpdateStatus(status); }
|
||||
}
|
||||
|
||||
public static void WriteError(string message) { WriteLine($"ERROR: {message}"); }
|
||||
|
||||
public static void WriteWarning(string message) { WriteLine($"WARNING: {message}"); }
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace EagleInstaller {
|
||||
public partial class MainForm : Form {
|
||||
private Panel _progressPanel;
|
||||
private TextBox _progressTextBox;
|
||||
private Label _statusLabel;
|
||||
private Button _exitButton;
|
||||
|
||||
public string ServerUrl => CredentialManager.ServerUrl;
|
||||
|
||||
public MainForm() { InitializeComponent(); }
|
||||
|
||||
private void InitializeComponent() {
|
||||
// Form properties
|
||||
this.Text = "Eagle0 Installer";
|
||||
this.Size = new Size(600, 450);
|
||||
this.StartPosition = FormStartPosition.CenterScreen;
|
||||
this.FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = true;
|
||||
|
||||
// Create progress panel
|
||||
CreateProgressPanel();
|
||||
|
||||
// Add panel to form
|
||||
this.Controls.Add(_progressPanel);
|
||||
}
|
||||
|
||||
private void CreateProgressPanel() {
|
||||
_progressPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10) };
|
||||
|
||||
// Title and status
|
||||
var titleLabel = new Label {
|
||||
Text = "Eagle0 Installer",
|
||||
Font = new Font("Microsoft Sans Serif", 14F, FontStyle.Bold),
|
||||
Location = new Point(10, 10),
|
||||
Size = new Size(200, 30)
|
||||
};
|
||||
|
||||
_statusLabel = new Label {
|
||||
Text = "Initializing...",
|
||||
Location = new Point(10, 45),
|
||||
Size = new Size(560, 23),
|
||||
ForeColor = Color.Blue
|
||||
};
|
||||
|
||||
// Progress text box with scrollbar
|
||||
_progressTextBox = new TextBox {
|
||||
Location = new Point(10, 75),
|
||||
Size = new Size(560, 310),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
Font = new Font("Consolas", 9F),
|
||||
BackColor = Color.Black,
|
||||
ForeColor = Color.LightGreen,
|
||||
TabIndex = 0
|
||||
};
|
||||
|
||||
// Exit button
|
||||
_exitButton = new Button {
|
||||
Text = "Exit",
|
||||
Location = new Point(490, 395),
|
||||
Size = new Size(80, 30)
|
||||
};
|
||||
_exitButton.Click += ExitButton_Click;
|
||||
|
||||
// Add controls to progress panel
|
||||
_progressPanel.Controls.AddRange(
|
||||
new Control[] { titleLabel, _statusLabel, _progressTextBox, _exitButton });
|
||||
}
|
||||
|
||||
public void UpdateStatus(string status) {
|
||||
if (_statusLabel.InvokeRequired) {
|
||||
_statusLabel.Invoke(new Action<string>(UpdateStatus), status);
|
||||
return;
|
||||
}
|
||||
_statusLabel.Text = status;
|
||||
}
|
||||
|
||||
public void AppendProgress(string message) {
|
||||
if (_progressTextBox.InvokeRequired) {
|
||||
_progressTextBox.Invoke(new Action<string>(AppendProgress), message);
|
||||
return;
|
||||
}
|
||||
|
||||
_progressTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
|
||||
_progressTextBox.SelectionStart = _progressTextBox.Text.Length;
|
||||
_progressTextBox.ScrollToCaret();
|
||||
}
|
||||
|
||||
private void ExitButton_Click(object sender, EventArgs e) { this.Close(); }
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace EagleInstaller {
|
||||
class Program {
|
||||
private static readonly string FileLocation = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"eagle0");
|
||||
private static readonly string ExeLocation = Path.Combine(FileLocation, "eagle0.exe");
|
||||
private static readonly string InvitationFilePath =
|
||||
Path.Combine(FileLocation, "invitation.json");
|
||||
|
||||
private static void CleanupTempInstallerFiles() {
|
||||
try {
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "eagle0_installer_update");
|
||||
if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, true); }
|
||||
} catch (Exception) {
|
||||
// Ignore cleanup failures - not critical
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractInvitationCode(string[] args) {
|
||||
foreach (string arg in args) {
|
||||
// Support --code=XXXX format
|
||||
if (arg.StartsWith("--code=", StringComparison.OrdinalIgnoreCase)) {
|
||||
return arg.Substring(7);
|
||||
}
|
||||
// Support -code=XXXX format
|
||||
if (arg.StartsWith("-code=", StringComparison.OrdinalIgnoreCase)) {
|
||||
return arg.Substring(6);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void SaveInvitationCode(string code) {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
Directory.CreateDirectory(FileLocation);
|
||||
|
||||
// Write simple JSON with the invitation code
|
||||
string json = $"{{\"invitation_code\":\"{code}\"}}";
|
||||
File.WriteAllText(InvitationFilePath, json);
|
||||
Logger.WriteLine($"Saved invitation code to {InvitationFilePath}");
|
||||
} catch (Exception ex) {
|
||||
Logger.WriteError($"Failed to save invitation code: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task Main(string[] args) {
|
||||
// Enable visual styles for Windows Forms
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
// Clean up any leftover temp installer files from previous updates
|
||||
CleanupTempInstallerFiles();
|
||||
|
||||
// Handle installer replacement if launched with --replace-installer flag
|
||||
if (args.Length > 0 && args[0] == "--replace-installer") {
|
||||
await HandleInstallerReplacement(args);
|
||||
return;
|
||||
}
|
||||
|
||||
bool showHelp = args.Length > 0 && (args[0] == "--help" || args[0] == "-h");
|
||||
|
||||
// Show help if requested
|
||||
if (showHelp) {
|
||||
ShowHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract invitation code from args (if provided)
|
||||
string invitationCode = ExtractInvitationCode(args);
|
||||
|
||||
// Create main form with default server URL
|
||||
var mainForm = new MainForm();
|
||||
Logger.Initialize(mainForm);
|
||||
|
||||
// Show the form and ensure handle is created
|
||||
mainForm.Show();
|
||||
mainForm.CreateControl();
|
||||
|
||||
// Start the installer logic in the background
|
||||
var installerTask = RunInstallerLogic(mainForm, invitationCode);
|
||||
|
||||
// Run the application with the main form
|
||||
Application.Run(mainForm);
|
||||
|
||||
// Wait for installer logic to complete
|
||||
await installerTask;
|
||||
}
|
||||
|
||||
private static async Task RunInstallerLogic(MainForm mainForm, string invitationCode) {
|
||||
try {
|
||||
string serverUrl = mainForm.ServerUrl;
|
||||
Logger.WriteLine($"Using server: {serverUrl}");
|
||||
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
Logger.WriteLine("Invitation code provided");
|
||||
}
|
||||
|
||||
EagleUpdater updater = new EagleUpdater(serverUrl);
|
||||
|
||||
// Stage 1: Check if installer itself needs update
|
||||
var installerUpdateInfo = await updater.CheckForInstallerUpdate();
|
||||
if (installerUpdateInfo.InstallerNeedsUpdate) {
|
||||
Logger.UpdateStatus("Installer update required...");
|
||||
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
|
||||
installerUpdateInfo.NewInstallerUrl,
|
||||
installerUpdateInfo.NewInstallerVersion);
|
||||
if (downloadSuccess) {
|
||||
Logger.UpdateStatus("New installer launched. Exiting current version.");
|
||||
|
||||
// Wait a moment to ensure new installer starts before we exit
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Exit the application directly
|
||||
Application.Exit();
|
||||
return; // Exit this version
|
||||
}
|
||||
|
||||
Logger.WriteError(
|
||||
"Failed to download new installer, continuing with current version...");
|
||||
}
|
||||
|
||||
// Stage 2: Proceed with normal game update
|
||||
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
|
||||
|
||||
if (success) {
|
||||
// Save invitation code before launching game (if provided)
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
SaveInvitationCode(invitationCode);
|
||||
}
|
||||
|
||||
Logger.UpdateStatus("Launching Eagle0...");
|
||||
Process.Start(ExeLocation);
|
||||
|
||||
// Exit the installer after launching the game
|
||||
Application.Exit();
|
||||
} else {
|
||||
Logger.WriteError("Update failed!");
|
||||
}
|
||||
} catch (Exception ex) { Logger.WriteError($"Unexpected error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static async Task HandleInstallerReplacement(string[] args) {
|
||||
if (args.Length < 2) {
|
||||
MessageBox.Show(
|
||||
"Error: --replace-installer requires path to old installer",
|
||||
"Installer Update Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
string oldInstallerPath = args[1];
|
||||
string currentInstallerPath =
|
||||
Environment.ProcessPath ??
|
||||
Path.Combine(AppContext.BaseDirectory, "EagleInstaller.exe");
|
||||
|
||||
// Show a progress form for the replacement process
|
||||
var progressForm = new Form {
|
||||
Text = "Updating Installer",
|
||||
Size = new System.Drawing.Size(400, 150),
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
MaximizeBox = false,
|
||||
MinimizeBox = false,
|
||||
StartPosition = FormStartPosition.CenterScreen
|
||||
};
|
||||
|
||||
var label = new Label {
|
||||
Text = "Updating installer, please wait...",
|
||||
Dock = DockStyle.Fill,
|
||||
TextAlign = ContentAlignment.MiddleCenter
|
||||
};
|
||||
progressForm.Controls.Add(label);
|
||||
|
||||
progressForm.Show();
|
||||
Application.DoEvents();
|
||||
|
||||
try {
|
||||
// Wait for the old process to exit - check if file is still locked
|
||||
label.Text = "Waiting for previous installer to close...";
|
||||
Application.DoEvents();
|
||||
|
||||
// Wait up to 30 seconds for the old installer file to be unlocked
|
||||
int maxWaitSeconds = 30;
|
||||
bool oldProcessExited = false;
|
||||
|
||||
for (int i = 0; i < maxWaitSeconds && !oldProcessExited; i++) {
|
||||
try {
|
||||
// Try to open the old installer file for writing
|
||||
// If this succeeds, the old process has released the file
|
||||
using (var fs = File.Open(
|
||||
oldInstallerPath,
|
||||
FileMode.Open,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None)) {
|
||||
// File is not locked, old process has exited
|
||||
oldProcessExited = true;
|
||||
label.Text = "Previous installer has exited, proceeding...";
|
||||
Application.DoEvents();
|
||||
}
|
||||
} catch (IOException) {
|
||||
// File is still locked by old process, wait a bit more
|
||||
label.Text = $"Waiting for previous installer to close... ({i + 1}s)";
|
||||
Application.DoEvents();
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (!oldProcessExited) {
|
||||
throw new Exception(
|
||||
$"Old installer process did not exit after {maxWaitSeconds} seconds");
|
||||
}
|
||||
|
||||
// Backup old installer
|
||||
string backupPath = oldInstallerPath + ".backup";
|
||||
label.Text = "Backing up current installer...";
|
||||
Application.DoEvents();
|
||||
if (File.Exists(oldInstallerPath)) {
|
||||
// Remove existing backup if it exists
|
||||
if (File.Exists(backupPath)) { File.Delete(backupPath); }
|
||||
File.Move(oldInstallerPath, backupPath);
|
||||
}
|
||||
Application.DoEvents();
|
||||
|
||||
// Copy new installer to old location
|
||||
label.Text = "Installing new version...";
|
||||
Application.DoEvents();
|
||||
File.Copy(currentInstallerPath, oldInstallerPath, true);
|
||||
Application.DoEvents();
|
||||
|
||||
label.Text = "Launching updated installer...";
|
||||
Application.DoEvents();
|
||||
|
||||
// Launch the newly installed version
|
||||
var processInfo = new System.Diagnostics.ProcessStartInfo {
|
||||
FileName = oldInstallerPath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
System.Diagnostics.Process.Start(processInfo);
|
||||
|
||||
label.Text = "Launching new installer...";
|
||||
Application.DoEvents();
|
||||
|
||||
// Exit immediately after launching - no delay needed
|
||||
Environment.Exit(0);
|
||||
|
||||
} catch (Exception e) {
|
||||
MessageBox.Show(
|
||||
$"Failed to replace installer: {e.Message}",
|
||||
"Installer Update Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
|
||||
// Restore backup if replacement failed
|
||||
string backupPath = oldInstallerPath + ".backup";
|
||||
if (File.Exists(backupPath) && !File.Exists(oldInstallerPath)) {
|
||||
try {
|
||||
File.Move(backupPath, oldInstallerPath);
|
||||
MessageBox.Show(
|
||||
"Installer backup restored.",
|
||||
"Recovery",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
} catch (Exception restoreEx) {
|
||||
MessageBox.Show(
|
||||
$"Failed to restore backup: {restoreEx.Message}",
|
||||
"Recovery Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
} finally { progressForm?.Close(); }
|
||||
}
|
||||
|
||||
private static void ShowHelp() {
|
||||
Console.WriteLine("Eagle0 Installer");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("USAGE:");
|
||||
Console.WriteLine(" EagleInstaller.exe [OPTIONS]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("OPTIONS:");
|
||||
Console.WriteLine(" --help, -h Show this help message");
|
||||
Console.WriteLine(" --code=CODE Invitation code for new account registration");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("NORMAL OPERATION:");
|
||||
Console.WriteLine(" Run without arguments to start the normal update process.");
|
||||
Console.WriteLine(" Downloads game files from assets.eagle0.net CDN.");
|
||||
}
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
|
||||
<publishUrl>../../../../../../../Documents/winbuild</publishUrl>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<SelfContained>false</SelfContained>
|
||||
<_IsPortable>true</_IsPortable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
|
||||
<publishUrl>/Volumes/Media/winbuild</publishUrl>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<SelfContained>false</SelfContained>
|
||||
<_IsPortable>true</_IsPortable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
|
||||
<publishUrl>../../installer</publishUrl>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<SelfContained>false</SelfContained>
|
||||
<_IsPortable>true</_IsPortable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
|
||||
<publishUrl>../../installer</publishUrl>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<SelfContained>false</SelfContained>
|
||||
<_IsPortable>true</_IsPortable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>AnyCPU</LastUsedPlatform>
|
||||
<publishUrl>../../installer</publishUrl>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<SelfContained>false</SelfContained>
|
||||
<_IsPortable>true</_IsPortable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<PublishDir>C:\Users\dan\OneDrive\Desktop\updater</PublishDir>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
</Project>
|
||||
@@ -1,8 +0,0 @@
|
||||
manifest_name = eagle0_manifest.txt
|
||||
remote_manifest_prefix = installer/v2/
|
||||
remote_asset_prefix = unity3d/win/
|
||||
|
||||
# Ed25519 public key for manifest signature verification (base64)
|
||||
# Generate key pair with: go run scripts/generate_manifest_keys.go
|
||||
# Copy the public key here after generating
|
||||
# manifest_public_key = YOUR_PUBLIC_KEY_HERE
|
||||
@@ -1,129 +0,0 @@
|
||||
# Eagle0 Installer GUI Update
|
||||
|
||||
The installer has been transformed from a console application to a modern Windows Forms GUI application that displays file download information in a scrollable window.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### **New GUI Components**
|
||||
|
||||
**1. MainForm.cs**
|
||||
- **Dual-panel design**: Login view and Progress view
|
||||
- **Login Panel**: Username, password, server URL, remember credentials checkbox
|
||||
- **Progress Panel**: Status label + scrollable text box with file download details
|
||||
- **Terminal-style output**: Black background, green text, console font
|
||||
- **Timestamped messages**: Each log entry shows `[HH:mm:ss] message`
|
||||
|
||||
**2. Logger.cs**
|
||||
- **Unified logging system**: Outputs to both console AND GUI
|
||||
- **Thread-safe**: Uses Invoke for cross-thread GUI updates
|
||||
- **Multiple log levels**: WriteLine, UpdateStatus, WriteError, WriteWarning
|
||||
- **Auto-scrolling**: Progress text box automatically scrolls to show latest messages
|
||||
|
||||
### **User Experience Flow**
|
||||
|
||||
**1. Application Startup**
|
||||
- Windows Forms application opens with professional login dialog
|
||||
- Saved credentials auto-populated (if available)
|
||||
- "Remember credentials" checkbox for convenience
|
||||
|
||||
**2. Login Process**
|
||||
- User enters/confirms credentials
|
||||
- Click "Login" button to proceed
|
||||
- Form switches to progress view
|
||||
|
||||
**3. Progress Display**
|
||||
- **Status line**: Shows current operation (e.g., "Downloading files...")
|
||||
- **Scrollable log**: Shows detailed file-by-file progress
|
||||
- **Color-coded messages**:
|
||||
- ✓ Green checkmarks for successful downloads
|
||||
- ✗ Red X marks for errors
|
||||
- Blue status updates
|
||||
- **Change Credentials button**: Allows switching back to login
|
||||
|
||||
**4. File Download Information**
|
||||
Now appears in the GUI progress window:
|
||||
```
|
||||
[14:32:15] Downloading: game/eagle0.exe
|
||||
[14:32:16] ✓ Verified and saved: game/eagle0.exe
|
||||
[14:32:16] Downloading: game/assets/textures.bundle
|
||||
[14:32:18] ✓ Verified and saved: game/assets/textures.bundle
|
||||
[14:32:18] Up to date: game/data.unity3d
|
||||
[14:32:18] All downloads completed successfully.
|
||||
```
|
||||
|
||||
### **Technical Implementation**
|
||||
|
||||
**Unified Logging System:**
|
||||
```csharp
|
||||
// Both console and GUI output
|
||||
Logger.WriteLine("Downloading: game/eagle0.exe");
|
||||
Logger.UpdateStatus("Update completed successfully!");
|
||||
Logger.WriteError("Network connection failed");
|
||||
```
|
||||
|
||||
**Thread-Safe GUI Updates:**
|
||||
```csharp
|
||||
public void AppendProgress(string message) {
|
||||
if (progressTextBox.InvokeRequired) {
|
||||
progressTextBox.Invoke(new Action<string>(AppendProgress), message);
|
||||
return;
|
||||
}
|
||||
progressTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
|
||||
progressTextBox.ScrollToCaret();
|
||||
}
|
||||
```
|
||||
|
||||
**Dual Output Support:**
|
||||
- **Console mode**: When run from command line, still shows console output
|
||||
- **GUI mode**: When double-clicked, shows Windows Forms interface
|
||||
- **Both simultaneously**: Console + GUI for debugging
|
||||
|
||||
### **Credential Management Integration**
|
||||
|
||||
All existing credential features work with the new GUI:
|
||||
- **Command line flags**: `--login`, `--clear-credentials`, `--help`
|
||||
- **Saved credentials**: Auto-populate form fields
|
||||
- **Authentication errors**: Show login form with error message
|
||||
- **Unity client sharing**: Credentials still saved for game client
|
||||
|
||||
### **File Structure Changes**
|
||||
|
||||
**New Files:**
|
||||
- `MainForm.cs` - Main Windows Forms interface
|
||||
- `Logger.cs` - Unified console/GUI logging system
|
||||
- `GUI_UPDATE.md` - This documentation
|
||||
|
||||
**Modified Files:**
|
||||
- `Program.cs` - GUI application lifecycle management
|
||||
- `EagleUpdater.cs` - All Console.WriteLine → Logger calls
|
||||
- `EagleInstaller.csproj` - Added Windows Forms support
|
||||
|
||||
### **Benefits**
|
||||
|
||||
1. **Professional Appearance**: Modern Windows Forms interface
|
||||
2. **Better User Experience**: Clear progress indication with scrollable log
|
||||
3. **File Download Visibility**: Users can see exactly which files are being processed
|
||||
4. **Error Transparency**: Color-coded success/failure indicators
|
||||
5. **Credential Management**: Integrated login with remember option
|
||||
6. **Backwards Compatibility**: Command line flags still work
|
||||
7. **Debug Friendly**: Both console and GUI output available
|
||||
|
||||
### **Example Output in GUI**
|
||||
|
||||
```
|
||||
STATUS: Fetching new manifest...
|
||||
[14:30:42] Checking for installer updates...
|
||||
[14:30:43] Installer is up to date
|
||||
[14:30:43] Update attempt 1 of 3...
|
||||
[14:30:43] Downloading: game/eagle0.exe
|
||||
[14:30:45] ✓ Verified and saved: game/eagle0.exe
|
||||
[14:30:45] Downloading: game/assets/music.bundle
|
||||
[14:30:47] ✓ Verified and saved: game/assets/music.bundle
|
||||
[14:30:47] Up to date: game/data.unity3d
|
||||
[14:30:47] Up to date: game/config.json
|
||||
[14:30:47] All downloads completed successfully.
|
||||
STATUS: Update completed successfully!
|
||||
[14:30:48] Launching Eagle0...
|
||||
```
|
||||
|
||||
The installer now provides a professional, user-friendly interface while maintaining all existing functionality including the two-stage installer updates and comprehensive credential management system.
|
||||
@@ -1,6 +0,0 @@
|
||||
# installer_version=1.1.0
|
||||
# installer_url=assets/installer/EagleInstaller.exe
|
||||
a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456 game/eagle0.exe
|
||||
b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567 game/data.unity3d
|
||||
c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890 game/assets/textures.bundle
|
||||
d4e5f6789012345678901234567890abcdef1234567890abcdef12345678901a game/assets/audio.bundle
|
||||
@@ -1159,13 +1159,20 @@ func handleGameHistoryAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// Settings data structures
|
||||
|
||||
type SettingInfo struct {
|
||||
Name string `json:"name"`
|
||||
SettingType string `json:"setting_type"`
|
||||
CurrentValue string `json:"current_value"`
|
||||
DefaultValue string `json:"default_value"`
|
||||
IsModified bool `json:"is_modified"`
|
||||
Name string `json:"name"`
|
||||
SettingType string `json:"setting_type"`
|
||||
CurrentValue string `json:"current_value"`
|
||||
DefaultValue string `json:"default_value"`
|
||||
IsModified bool `json:"is_modified"`
|
||||
DropdownOptions []string `json:"dropdown_options,omitempty"`
|
||||
}
|
||||
|
||||
// LLM model dropdown options (recommended models first)
|
||||
var llmProviderOptions = []string{"gemini", "openai", "claude"}
|
||||
var openAiModelOptions = []string{"gpt-4.1-mini", "gpt-5-mini", "gpt-5.1", "gpt-5.2", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"}
|
||||
var claudeModelOptions = []string{"claude-3-5-haiku-20241022", "claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20241022"}
|
||||
var geminiModelOptions = []string{"gemini-2.5-flash-lite", "gemini-2.5-flash", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-3-pro-preview"}
|
||||
|
||||
type SettingsPageData struct {
|
||||
Title string
|
||||
Settings []SettingInfo
|
||||
@@ -1185,6 +1192,42 @@ type SettingUpdateResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// getDropdownOptionsForSetting returns dropdown options for a setting if it has any
|
||||
func getDropdownOptionsForSetting(name string) []string {
|
||||
switch name {
|
||||
case "LlmProvider":
|
||||
return llmProviderOptions
|
||||
case "OpenAiModelName":
|
||||
return openAiModelOptions
|
||||
case "ClaudeModelName":
|
||||
return claudeModelOptions
|
||||
case "GeminiModelName":
|
||||
return geminiModelOptions
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSettingInputHTML generates the HTML for a setting's input (text input or dropdown)
|
||||
func generateSettingInputHTML(name, currentValue string) string {
|
||||
options := getDropdownOptionsForSetting(name)
|
||||
if options != nil {
|
||||
// Generate dropdown
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<select name="value" class="setting-input setting-select">`)
|
||||
for _, opt := range options {
|
||||
if opt == currentValue {
|
||||
sb.WriteString(fmt.Sprintf(`<option value="%s" selected>%s</option>`, opt, opt))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(`<option value="%s">%s</option>`, opt, opt))
|
||||
}
|
||||
}
|
||||
sb.WriteString(`</select>`)
|
||||
return sb.String()
|
||||
}
|
||||
// Generate text input
|
||||
return fmt.Sprintf(`<input type="text" name="value" value="%s" class="setting-input">`, currentValue)
|
||||
}
|
||||
|
||||
func fetchSettings(ctx context.Context, filter string) ([]SettingInfo, error) {
|
||||
resp, err := gameAdminClient.GetSettings(ctx, &gameadminpb.GetSettingsRequest{
|
||||
Filter: filter,
|
||||
@@ -1195,13 +1238,23 @@ func fetchSettings(ctx context.Context, filter string) ([]SettingInfo, error) {
|
||||
|
||||
settings := make([]SettingInfo, len(resp.Settings))
|
||||
for i, s := range resp.Settings {
|
||||
settings[i] = SettingInfo{
|
||||
setting := SettingInfo{
|
||||
Name: s.Name,
|
||||
SettingType: s.SettingType,
|
||||
CurrentValue: s.CurrentValue,
|
||||
DefaultValue: s.DefaultValue,
|
||||
IsModified: s.CurrentValue != s.DefaultValue,
|
||||
}
|
||||
// Add dropdown options for LLM settings
|
||||
switch s.Name {
|
||||
case "LlmProvider":
|
||||
setting.DropdownOptions = llmProviderOptions
|
||||
case "OpenAiModelName":
|
||||
setting.DropdownOptions = openAiModelOptions
|
||||
case "ClaudeModelName":
|
||||
setting.DropdownOptions = claudeModelOptions
|
||||
}
|
||||
settings[i] = setting
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -1350,18 +1403,19 @@ func handleSettingUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if updated.IsModified {
|
||||
modifiedClass = " modified"
|
||||
}
|
||||
inputHTML := generateSettingInputHTML(updated.Name, updated.CurrentValue)
|
||||
fmt.Fprintf(w, `<tr class="setting-row%s" id="setting-%s">
|
||||
<td class="setting-name">%s</td>
|
||||
<td class="setting-type">%s</td>
|
||||
<td class="setting-value">
|
||||
<form hx-post="/settings/update" hx-target="#setting-%s" hx-swap="outerHTML">
|
||||
<input type="hidden" name="name" value="%s">
|
||||
<input type="text" name="value" value="%s" class="setting-input">
|
||||
%s
|
||||
<button type="submit" class="btn-small">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="setting-default">%s</td>
|
||||
</tr>`, modifiedClass, updated.Name, updated.Name, updated.SettingType, updated.Name, updated.Name, updated.CurrentValue, updated.DefaultValue)
|
||||
</tr>`, modifiedClass, updated.Name, updated.Name, updated.SettingType, updated.Name, updated.Name, inputHTML, updated.DefaultValue)
|
||||
}
|
||||
|
||||
// Player management handlers
|
||||
|
||||
@@ -287,6 +287,11 @@ nav .brand {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.setting-input.setting-select {
|
||||
width: 220px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-default {
|
||||
font-size: 0.8rem;
|
||||
color: var(--pico-muted-color);
|
||||
|
||||
@@ -98,11 +98,17 @@
|
||||
}
|
||||
|
||||
.login-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -31,7 +31,16 @@
|
||||
<td class="setting-value">
|
||||
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
{{if .DropdownOptions}}
|
||||
<select name="value" class="setting-input setting-select">
|
||||
{{$current := .CurrentValue}}
|
||||
{{range .DropdownOptions}}
|
||||
<option value="{{.}}"{{if eq . $current}} selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
|
||||
{{end}}
|
||||
<button type="submit" class="btn-small">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
<td class="setting-value">
|
||||
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
{{if .DropdownOptions}}
|
||||
<select name="value" class="setting-input setting-select">
|
||||
{{$current := .CurrentValue}}
|
||||
{{range .DropdownOptions}}
|
||||
<option value="{{.}}"{{if eq . $current}} selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
|
||||
{{end}}
|
||||
<button type="submit" class="btn-small">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -45,7 +45,7 @@ const oauthSessionExpiration = 10 * time.Minute
|
||||
func NewInvitationHTTPHandler(invitationService *InvitationService, userService *UserService, oauthService *OAuthService) *InvitationHTTPHandler {
|
||||
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
|
||||
if installerURL == "" {
|
||||
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
|
||||
installerURL = "https://assets.eagle0.net/installer/Eagle0.exe"
|
||||
}
|
||||
macInstallerURL := os.Getenv("MAC_INSTALLER_URL")
|
||||
if macInstallerURL == "" {
|
||||
@@ -562,7 +562,7 @@ echo.
|
||||
echo Downloading Eagle0 installer...
|
||||
echo.
|
||||
|
||||
curl -L -o "%%TEMP%%\EagleInstaller.exe" "%s"
|
||||
curl -L -o "%%TEMP%%\Eagle0.exe" "%s"
|
||||
if %%ERRORLEVEL%% neq 0 (
|
||||
echo.
|
||||
echo Failed to download installer. Please check your internet connection.
|
||||
@@ -575,7 +575,7 @@ if %%ERRORLEVEL%% neq 0 (
|
||||
echo.
|
||||
echo Starting installer...
|
||||
echo.
|
||||
start "" "%%TEMP%%\EagleInstaller.exe" --code=%s
|
||||
start "" "%%TEMP%%\Eagle0.exe" --code=%s
|
||||
|
||||
echo.
|
||||
echo The installer should now be running.
|
||||
@@ -731,11 +731,16 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
|
||||
text-align: center;
|
||||
}
|
||||
.oauth-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.oauth-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.oauth-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1187,7 +1192,7 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
|
||||
<h3>Installation Instructions:</h3>
|
||||
<ol>
|
||||
<li>Click "Download for Windows" to download the installer</li>
|
||||
<li>Run <strong>EagleInstaller.exe</strong></li>
|
||||
<li>Run <strong>Eagle0.exe</strong></li>
|
||||
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
|
||||
<li>Launch Eagle0 and sign in with the same account you just used</li>
|
||||
</ol>
|
||||
|
||||
@@ -52,7 +52,7 @@ func NewEmailService() *EmailService {
|
||||
fromName = "Eagle0 Game"
|
||||
}
|
||||
if installerURL == "" {
|
||||
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
|
||||
installerURL = "https://assets.eagle0.net/installer/Eagle0.exe"
|
||||
}
|
||||
if serverBaseURL == "" {
|
||||
serverBaseURL = "https://prod.eagle0.net"
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
var bucketName = "eagle0-assets"
|
||||
var defaultInstallerKey = "installer/EagleInstaller.exe"
|
||||
var defaultInstallerKey = "installer/Eagle0.exe"
|
||||
var versionKey = "installer/version.txt"
|
||||
|
||||
func sha(filePath string) (string, error) {
|
||||
@@ -68,7 +68,7 @@ func main() {
|
||||
log.Printf("Successfully uploaded installer to s3://%s/%s", bucketName, installerKey)
|
||||
|
||||
// Create a version/metadata file with the SHA256
|
||||
versionContent := fmt.Sprintf("sha256=%s\nversion=1.0.0\nurl=installer/EagleInstaller.exe", installerSha)
|
||||
versionContent := fmt.Sprintf("sha256=%s\nversion=1.0.0\nurl=%s", installerSha, installerKey)
|
||||
|
||||
// Write version info to temp file
|
||||
tempVersionFile := filepath.Join(os.TempDir(), "installer_version.txt")
|
||||
|
||||
+7
-1
@@ -28,6 +28,12 @@ func main() {
|
||||
|
||||
capitalizedSettingType := capitalized(settingType)
|
||||
|
||||
// For String types, wrap the value in quotes
|
||||
formattedValue := settingValue
|
||||
if strings.ToLower(settingType) == "string" {
|
||||
formattedValue = fmt.Sprintf("\"%s\"", settingValue)
|
||||
}
|
||||
|
||||
fmt.Printf(`// Generated file, do not edit!
|
||||
|
||||
package net.eagle0.eagle.library.settings
|
||||
@@ -35,5 +41,5 @@ package net.eagle0.eagle.library.settings
|
||||
import net.eagle0.eagle.library.settings.base.%sSetting
|
||||
|
||||
object %s extends %sSetting(%s)
|
||||
`, capitalizedSettingType, settingName, capitalizedSettingType, settingValue)
|
||||
`, capitalizedSettingType, settingName, capitalizedSettingType, formattedValue)
|
||||
}
|
||||
|
||||
+12
-3
@@ -76,7 +76,7 @@ func generateSettingsLoader(settings []Setting) string {
|
||||
|
||||
sb.WriteString("package net.eagle0.eagle.library.settings.loaders\n\n")
|
||||
sb.WriteString("import net.eagle0.common.TsvUtils\n")
|
||||
sb.WriteString("import net.eagle0.eagle.library.settings.base.{DoubleSetting, IntSetting}\n")
|
||||
sb.WriteString("import net.eagle0.eagle.library.settings.base.{DoubleSetting, IntSetting, StringSetting}\n")
|
||||
sb.WriteString("// Auto-generated imports for all settings\n")
|
||||
sb.WriteString("import net.eagle0.eagle.library.settings._\n\n")
|
||||
sb.WriteString("import java.net.URL\n\n")
|
||||
@@ -111,12 +111,16 @@ func generateSettingsLoader(settings []Setting) string {
|
||||
|
||||
sb.WriteString(" def getAllSettings: Vector[SettingInfo] = Vector(\n")
|
||||
for i, setting := range settings {
|
||||
if setting.Type == "Int" {
|
||||
switch setting.Type {
|
||||
case "Int":
|
||||
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Int\", %s.intValue.toString, \"%s\")",
|
||||
setting.Name, setting.Name, setting.DefaultValue))
|
||||
} else {
|
||||
case "Double":
|
||||
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Double\", %s.doubleValue.toString, \"%s\")",
|
||||
setting.Name, setting.Name, setting.DefaultValue))
|
||||
case "String":
|
||||
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"String\", %s.stringValue, \"%s\")",
|
||||
setting.Name, setting.Name, setting.DefaultValue))
|
||||
}
|
||||
if i < len(settings)-1 {
|
||||
sb.WriteString(",")
|
||||
@@ -142,6 +146,9 @@ func generateSettingsLoader(settings []Setting) string {
|
||||
case "double" =>
|
||||
obj.asInstanceOf[DoubleSetting].setDoubleValue(setting.value.toDouble)
|
||||
true
|
||||
case "string" =>
|
||||
obj.asInstanceOf[StringSetting].setStringValue(setting.value)
|
||||
true
|
||||
case typeName =>
|
||||
throw new Exception(s"Invalid setting type $typeName")
|
||||
}
|
||||
@@ -182,6 +189,8 @@ func generateSettingsLoader(settings []Setting) string {
|
||||
intSetting.setIntValue(value.toInt)
|
||||
case doubleSetting: DoubleSetting =>
|
||||
doubleSetting.setDoubleValue(value.toDouble)
|
||||
case stringSetting: StringSetting =>
|
||||
stringSetting.setStringValue(value)
|
||||
case _ =>
|
||||
throw NoSuchSettingException(key)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
# Pure Go library (uses stub for WebView)
|
||||
go_library(
|
||||
name = "installer_lib",
|
||||
srcs = [
|
||||
"config.go",
|
||||
"main.go",
|
||||
"ui.go",
|
||||
"ui_interface.go",
|
||||
"ui_webview_stub.go",
|
||||
"ui_webview_windows.go",
|
||||
"updater.go",
|
||||
],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/clients/win/installer",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = select({
|
||||
"@io_bazel_rules_go//go/platform:windows": [
|
||||
"@com_github_webview_webview_go//:webview_go",
|
||||
],
|
||||
"//conditions:default": [],
|
||||
}),
|
||||
)
|
||||
|
||||
# Windows x86_64 installer binary
|
||||
# Windows x86_64 installer binary (pure Go, console-only)
|
||||
# Build with: bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64
|
||||
# The manifest public key is injected at build time via x_defs
|
||||
# The resource_windows.syso file embeds the application icon and version info
|
||||
go_binary(
|
||||
name = "eagle_installer_windows_amd64",
|
||||
srcs = ["resource_windows.syso"],
|
||||
@@ -26,17 +34,161 @@ go_binary(
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
x_defs = {
|
||||
"main.manifestPublicKey": "{MANIFEST_PUBLIC_KEY}",
|
||||
"main.manifestPublicKey": "{STABLE_MANIFEST_PUBLIC_KEY}",
|
||||
},
|
||||
)
|
||||
|
||||
# Native binary for testing on current platform
|
||||
# Windows x86_64 installer with WebView GUI
|
||||
# Build with: bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview
|
||||
# This uses CGO cross-compilation with llvm-mingw toolchain
|
||||
genrule(
|
||||
name = "eagle_installer_windows_amd64_webview",
|
||||
srcs = [
|
||||
"config.go",
|
||||
"main.go",
|
||||
"ui.go",
|
||||
"ui_interface.go",
|
||||
"ui_webview_windows.go",
|
||||
"updater.go",
|
||||
"resource_windows_amd64.syso",
|
||||
"@llvm_mingw//:all_files",
|
||||
"@go_default_sdk//:files",
|
||||
],
|
||||
outs = ["Eagle0.exe"],
|
||||
cmd = """
|
||||
set -e
|
||||
|
||||
# Find llvm-mingw root
|
||||
LLVM_MINGW=""
|
||||
for f in $(locations @llvm_mingw//:all_files); do
|
||||
if [[ "$$f" == *"/bin/x86_64-w64-mingw32-clang" ]]; then
|
||||
LLVM_MINGW=$$(cd $$(dirname $$(dirname $$f)) && pwd)
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$$LLVM_MINGW" ]]; then
|
||||
echo "ERROR: Could not find llvm-mingw toolchain"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using llvm-mingw at: $$LLVM_MINGW"
|
||||
|
||||
# Find Go SDK from Bazel dependency - use absolute paths
|
||||
GO=""
|
||||
GO_SDK=""
|
||||
SANDBOX_ROOT="$$PWD"
|
||||
for f in $(locations @go_default_sdk//:files); do
|
||||
if [[ "$$f" == */bin/go && ! "$$f" == *.* ]]; then
|
||||
GO="$$SANDBOX_ROOT/$$f"
|
||||
GO_SDK=$$(cd $$(dirname $$(dirname "$$SANDBOX_ROOT/$$f")) && pwd)
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$$GO" ]]; then
|
||||
echo "ERROR: Could not find Go binary in @go_default_sdk//:files"
|
||||
echo "Available files:"
|
||||
for f in $(locations @go_default_sdk//:files); do
|
||||
echo " $$f"
|
||||
done | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export GOROOT="$$GO_SDK"
|
||||
echo "Using Go SDK at: $$GO_SDK"
|
||||
$$GO version
|
||||
|
||||
# Set up cross-compilation environment
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export CC="$$LLVM_MINGW/bin/x86_64-w64-mingw32-clang"
|
||||
export CXX="$$LLVM_MINGW/bin/x86_64-w64-mingw32-clang++"
|
||||
export AR="$$LLVM_MINGW/bin/x86_64-w64-mingw32-ar"
|
||||
|
||||
# Verify cross-compiler works
|
||||
echo "Testing cross-compiler..."
|
||||
$$CC --version | head -1
|
||||
|
||||
# Create temp build directory
|
||||
BUILD_DIR=$$(mktemp -d)
|
||||
trap "chmod -R u+w $$BUILD_DIR 2>/dev/null; rm -rf $$BUILD_DIR" EXIT
|
||||
|
||||
# Set up Go environment variables for sandbox
|
||||
export HOME=$$BUILD_DIR
|
||||
export GOPATH=$$BUILD_DIR/go
|
||||
export GOCACHE=$$BUILD_DIR/cache
|
||||
mkdir -p $$GOPATH $$GOCACHE
|
||||
|
||||
# Create Go module structure
|
||||
mkdir -p $$BUILD_DIR/installer
|
||||
cd $$BUILD_DIR/installer
|
||||
|
||||
# Initialize go module
|
||||
cat > go.mod << 'GOMOD'
|
||||
module github.com/nolen777/eagle0/src/main/go/net/eagle0/clients/win/installer
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
GOMOD
|
||||
|
||||
cat > go.sum << 'GOSUM'
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
|
||||
GOSUM
|
||||
|
||||
# Copy source files from Bazel sandbox (exclude Go SDK and llvm files)
|
||||
echo "Copying source files from $$SANDBOX_ROOT..."
|
||||
for src in $(SRCS); do
|
||||
# Skip Go SDK and toolchain files
|
||||
if [[ "$$src" == *"go_default_sdk"* || "$$src" == *"llvm_mingw"* || "$$src" == *"rules_go"* ]]; then
|
||||
continue
|
||||
fi
|
||||
case "$$src" in
|
||||
*.go)
|
||||
echo " Copying: $$src"
|
||||
cp "$$SANDBOX_ROOT/$$src" . || { echo "Failed to copy $$src"; exit 1; }
|
||||
;;
|
||||
*.syso)
|
||||
# Copy the 64-bit resource file (contains icon and version info)
|
||||
echo " Copying resource: $$src"
|
||||
cp "$$SANDBOX_ROOT/$$src" ./resource_windows.syso || { echo "Failed to copy $$src"; exit 1; }
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "Files copied:"
|
||||
ls -la *.go 2>/dev/null || echo "No files copied!"
|
||||
|
||||
# Download webview dependency
|
||||
$$GO mod download
|
||||
|
||||
# Build with GUI subsystem flag
|
||||
# MANIFEST_PUBLIC_KEY will be empty if not set via --action_env, which is fine
|
||||
# (installer handles empty public key gracefully by skipping signature verification)
|
||||
echo "Building Windows installer with WebView..."
|
||||
OUTPUT_EXE="$$SANDBOX_ROOT/$@"
|
||||
$$GO build -v \\
|
||||
-ldflags="-H windowsgui -X 'main.manifestPublicKey=$$MANIFEST_PUBLIC_KEY'" \\
|
||||
-o "$$OUTPUT_EXE" \\
|
||||
.
|
||||
|
||||
echo "Build complete!"
|
||||
ls -la "$$OUTPUT_EXE"
|
||||
""",
|
||||
stamp = 1,
|
||||
# Exclude from wildcard patterns - this target requires --action_env=MANIFEST_PUBLIC_KEY
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
# Native binary for testing on current platform (console only)
|
||||
go_binary(
|
||||
name = "eagle_installer",
|
||||
embed = [":installer_lib"],
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
x_defs = {
|
||||
"main.manifestPublicKey": "{MANIFEST_PUBLIC_KEY}",
|
||||
"main.manifestPublicKey": "{STABLE_MANIFEST_PUBLIC_KEY}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
fileLocation string
|
||||
exeLocation string
|
||||
invitationFile string
|
||||
ui *UI
|
||||
fileLocation string
|
||||
exeLocation string
|
||||
invitationFile string
|
||||
ui UIInterface
|
||||
invitationCode string
|
||||
replaceInstaller string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -32,7 +34,7 @@ func main() {
|
||||
helpFlag := flag.Bool("help", false, "Show help message")
|
||||
hFlag := flag.Bool("h", false, "Show help message")
|
||||
codeFlag := flag.String("code", "", "Invitation code for new account registration")
|
||||
replaceInstaller := flag.String("replace-installer", "", "Path to old installer to replace (internal use)")
|
||||
replaceFlag := flag.String("replace-installer", "", "Path to old installer to replace (internal use)")
|
||||
flag.Parse()
|
||||
|
||||
if *helpFlag || *hFlag {
|
||||
@@ -40,21 +42,41 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
ui = NewUI()
|
||||
// Store flags for use in runInstallerWithUI
|
||||
invitationCode = *codeFlag
|
||||
replaceInstaller = *replaceFlag
|
||||
|
||||
// Initialize UI - try WebView first, fall back to console
|
||||
if webviewUI := NewWebViewUI(); webviewUI != nil {
|
||||
ui = webviewUI
|
||||
// Run installer logic in a goroutine since WebView needs the main thread
|
||||
go runInstallerWithUI()
|
||||
// WebView event loop (blocks until window closes)
|
||||
ui.Run()
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to console UI
|
||||
ui = NewConsoleUI()
|
||||
runInstallerWithUI()
|
||||
}
|
||||
|
||||
// runInstallerWithUI runs the installer logic with the initialized UI
|
||||
func runInstallerWithUI() {
|
||||
defer ui.Close()
|
||||
|
||||
// Clean up temp installer files from previous updates
|
||||
cleanupTempInstallerFiles()
|
||||
|
||||
// Handle installer replacement if launched with --replace-installer flag
|
||||
if *replaceInstaller != "" {
|
||||
handleInstallerReplacement(*replaceInstaller)
|
||||
if replaceInstaller != "" {
|
||||
handleInstallerReplacement(replaceInstaller)
|
||||
return
|
||||
}
|
||||
|
||||
// Run the main installer logic
|
||||
ui.ShowBanner()
|
||||
if err := runInstallerLogic(*codeFlag); err != nil {
|
||||
if err := runInstallerLogic(invitationCode); err != nil {
|
||||
ui.ShowError(fmt.Sprintf("%v", err))
|
||||
ui.WaitForKeypress("Press Enter to exit...")
|
||||
os.Exit(1)
|
||||
@@ -65,7 +87,7 @@ func showHelp() {
|
||||
fmt.Println("Eagle0 Installer")
|
||||
fmt.Println()
|
||||
fmt.Println("USAGE:")
|
||||
fmt.Println(" EagleInstaller.exe [OPTIONS]")
|
||||
fmt.Println(" Eagle0.exe [OPTIONS]")
|
||||
fmt.Println()
|
||||
fmt.Println("OPTIONS:")
|
||||
fmt.Println(" --help, -h Show this help message")
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 124 KiB |
@@ -6,21 +6,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// UI provides a simple console-based progress display
|
||||
type UI struct {
|
||||
// ConsoleUI provides a simple console-based progress display.
|
||||
// It implements UIInterface and serves as the fallback UI when WebView is not available.
|
||||
type ConsoleUI struct {
|
||||
lastStatus string
|
||||
lastProgress int
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
func NewUI() *UI {
|
||||
return &UI{
|
||||
// Ensure ConsoleUI implements UIInterface
|
||||
var _ UIInterface = (*ConsoleUI)(nil)
|
||||
|
||||
func NewConsoleUI() *ConsoleUI {
|
||||
return &ConsoleUI{
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ShowBanner displays the application header
|
||||
func (ui *UI) ShowBanner() {
|
||||
func (ui *ConsoleUI) ShowBanner() {
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════════════════════════════════════════════════╗")
|
||||
fmt.Println("║ Eagle0 Installer ║")
|
||||
@@ -29,7 +33,7 @@ func (ui *UI) ShowBanner() {
|
||||
}
|
||||
|
||||
// UpdateStatus shows the current status message
|
||||
func (ui *UI) UpdateStatus(status string) {
|
||||
func (ui *ConsoleUI) UpdateStatus(status string) {
|
||||
if status != ui.lastStatus {
|
||||
ui.lastStatus = status
|
||||
fmt.Printf("► %s\n", status)
|
||||
@@ -37,7 +41,7 @@ func (ui *UI) UpdateStatus(status string) {
|
||||
}
|
||||
|
||||
// UpdateProgress shows a progress bar (0-100)
|
||||
func (ui *UI) UpdateProgress(percent int, description string) {
|
||||
func (ui *ConsoleUI) UpdateProgress(percent int, description string) {
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
@@ -64,33 +68,43 @@ func (ui *UI) UpdateProgress(percent int, description string) {
|
||||
}
|
||||
|
||||
// ShowSuccess displays a success message
|
||||
func (ui *UI) ShowSuccess(message string) {
|
||||
func (ui *ConsoleUI) ShowSuccess(message string) {
|
||||
fmt.Printf("\n✓ %s\n", message)
|
||||
}
|
||||
|
||||
// ShowError displays an error message
|
||||
func (ui *UI) ShowError(message string) {
|
||||
func (ui *ConsoleUI) ShowError(message string) {
|
||||
fmt.Printf("\n✗ ERROR: %s\n", message)
|
||||
}
|
||||
|
||||
// ShowWarning displays a warning message
|
||||
func (ui *UI) ShowWarning(message string) {
|
||||
func (ui *ConsoleUI) ShowWarning(message string) {
|
||||
fmt.Printf("\n⚠ WARNING: %s\n", message)
|
||||
}
|
||||
|
||||
// ShowInfo displays an info message
|
||||
func (ui *UI) ShowInfo(message string) {
|
||||
func (ui *ConsoleUI) ShowInfo(message string) {
|
||||
fmt.Printf(" %s\n", message)
|
||||
}
|
||||
|
||||
// WaitForKeypress waits for user input before continuing
|
||||
func (ui *UI) WaitForKeypress(prompt string) {
|
||||
func (ui *ConsoleUI) WaitForKeypress(prompt string) {
|
||||
fmt.Printf("\n%s", prompt)
|
||||
fmt.Scanln()
|
||||
}
|
||||
|
||||
// ShowElapsedTime shows how long the operation took
|
||||
func (ui *UI) ShowElapsedTime() {
|
||||
func (ui *ConsoleUI) ShowElapsedTime() {
|
||||
elapsed := time.Since(ui.startTime)
|
||||
fmt.Printf("\nCompleted in %.1f seconds\n", elapsed.Seconds())
|
||||
}
|
||||
|
||||
// Run is a no-op for console UI (no event loop needed)
|
||||
func (ui *ConsoleUI) Run() {
|
||||
// Console UI doesn't need an event loop
|
||||
}
|
||||
|
||||
// Close is a no-op for console UI (no cleanup needed)
|
||||
func (ui *ConsoleUI) Close() {
|
||||
// Console UI doesn't need cleanup
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
// UIInterface defines the interface for installer UI implementations.
|
||||
// This allows switching between console UI (fallback) and WebView UI (Windows with WebView2).
|
||||
type UIInterface interface {
|
||||
// ShowBanner displays the application header/title
|
||||
ShowBanner()
|
||||
|
||||
// UpdateStatus shows the current status message
|
||||
UpdateStatus(status string)
|
||||
|
||||
// UpdateProgress shows a progress bar (0-100) with description
|
||||
UpdateProgress(percent int, description string)
|
||||
|
||||
// ShowSuccess displays a success message
|
||||
ShowSuccess(message string)
|
||||
|
||||
// ShowError displays an error message
|
||||
ShowError(message string)
|
||||
|
||||
// ShowWarning displays a warning message
|
||||
ShowWarning(message string)
|
||||
|
||||
// ShowInfo displays an info message
|
||||
ShowInfo(message string)
|
||||
|
||||
// WaitForKeypress waits for user input before continuing
|
||||
WaitForKeypress(prompt string)
|
||||
|
||||
// ShowElapsedTime shows how long the operation took
|
||||
ShowElapsedTime()
|
||||
|
||||
// Run starts the UI event loop (blocking for WebView, no-op for console)
|
||||
Run()
|
||||
|
||||
// Close cleans up UI resources
|
||||
Close()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build !cgo || !windows
|
||||
|
||||
package main
|
||||
|
||||
// WebViewUI stub for pure Go builds (no CGO).
|
||||
// This stub allows the code to compile without CGO/WebView dependencies.
|
||||
// NewWebViewUI returns nil, causing main.go to fall back to ConsoleUI.
|
||||
type WebViewUI struct{}
|
||||
|
||||
// Ensure WebViewUI implements UIInterface (for compile-time checking)
|
||||
var _ UIInterface = (*WebViewUI)(nil)
|
||||
|
||||
// NewWebViewUI returns nil in pure Go builds (no WebView available).
|
||||
func NewWebViewUI() *WebViewUI {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The following methods implement UIInterface but will never be called
|
||||
// since NewWebViewUI returns nil and main.go checks for nil.
|
||||
|
||||
func (ui *WebViewUI) ShowBanner() {}
|
||||
func (ui *WebViewUI) UpdateStatus(status string) {}
|
||||
func (ui *WebViewUI) UpdateProgress(percent int, desc string) {}
|
||||
func (ui *WebViewUI) ShowSuccess(message string) {}
|
||||
func (ui *WebViewUI) ShowError(message string) {}
|
||||
func (ui *WebViewUI) ShowWarning(message string) {}
|
||||
func (ui *WebViewUI) ShowInfo(message string) {}
|
||||
func (ui *WebViewUI) WaitForKeypress(prompt string) {}
|
||||
func (ui *WebViewUI) ShowElapsedTime() {}
|
||||
func (ui *WebViewUI) Run() {}
|
||||
func (ui *WebViewUI) Close() {}
|
||||
@@ -0,0 +1,268 @@
|
||||
//go:build cgo && windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/webview/webview_go"
|
||||
)
|
||||
|
||||
// WebViewUI provides a WebView2-based GUI for the installer on Windows.
|
||||
// It implements UIInterface and provides a modern HTML/CSS/JS interface.
|
||||
type WebViewUI struct {
|
||||
w webview.WebView
|
||||
startTime time.Time
|
||||
ready chan struct{}
|
||||
mu sync.Mutex
|
||||
lastStatus string
|
||||
}
|
||||
|
||||
// Ensure WebViewUI implements UIInterface
|
||||
var _ UIInterface = (*WebViewUI)(nil)
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e8e8e8;
|
||||
height: 100vh;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.status {
|
||||
font-size: 16px;
|
||||
margin: 20px 0;
|
||||
min-height: 24px;
|
||||
color: #b8b8b8;
|
||||
}
|
||||
.progress-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #00d4ff 0%, #7b2cbf 100%);
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease-out;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.percentage {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
}
|
||||
.message {
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
.message.success {
|
||||
display: block;
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border-left: 4px solid #00ff88;
|
||||
color: #00ff88;
|
||||
}
|
||||
.message.error {
|
||||
display: block;
|
||||
background: rgba(255, 71, 87, 0.15);
|
||||
border-left: 4px solid #ff4757;
|
||||
color: #ff4757;
|
||||
}
|
||||
.message.warning {
|
||||
display: block;
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-left: 4px solid #ffc107;
|
||||
color: #ffc107;
|
||||
}
|
||||
.message.info {
|
||||
display: block;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border-left: 4px solid #00d4ff;
|
||||
color: #a0d4e8;
|
||||
}
|
||||
.elapsed {
|
||||
margin-top: auto;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">EAGLE0</div>
|
||||
<div class="status" id="status">Initializing...</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" id="progress"></div>
|
||||
</div>
|
||||
<div class="percentage" id="percentage">0%</div>
|
||||
<div class="message" id="message"></div>
|
||||
<div class="elapsed" id="elapsed"></div>
|
||||
<script>
|
||||
function updateProgress(percent, description) {
|
||||
document.getElementById('progress').style.width = percent + '%';
|
||||
document.getElementById('percentage').textContent = percent + '%';
|
||||
if (description) {
|
||||
document.getElementById('status').textContent = description;
|
||||
}
|
||||
}
|
||||
function updateStatus(status) {
|
||||
document.getElementById('status').textContent = status;
|
||||
}
|
||||
function showMessage(type, text) {
|
||||
const msg = document.getElementById('message');
|
||||
msg.className = 'message ' + type;
|
||||
msg.textContent = text;
|
||||
}
|
||||
function showElapsed(seconds) {
|
||||
document.getElementById('elapsed').textContent = 'Completed in ' + seconds.toFixed(1) + ' seconds';
|
||||
}
|
||||
function clearMessage() {
|
||||
document.getElementById('message').className = 'message';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// NewWebViewUI creates a new WebView-based UI.
|
||||
// Returns nil if WebView2 is not available.
|
||||
func NewWebViewUI() *WebViewUI {
|
||||
w := webview.New(false)
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
w.SetTitle("Eagle0 Installer")
|
||||
w.SetSize(520, 340, webview.HintFixed)
|
||||
w.SetHtml(htmlTemplate)
|
||||
|
||||
ui := &WebViewUI{
|
||||
w: w,
|
||||
startTime: time.Now(),
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
// ShowBanner is a no-op for WebView (logo is in HTML)
|
||||
func (ui *WebViewUI) ShowBanner() {
|
||||
// Logo is already in the HTML template
|
||||
}
|
||||
|
||||
// UpdateStatus shows the current status message
|
||||
func (ui *WebViewUI) UpdateStatus(status string) {
|
||||
ui.mu.Lock()
|
||||
defer ui.mu.Unlock()
|
||||
|
||||
if status == ui.lastStatus {
|
||||
return
|
||||
}
|
||||
ui.lastStatus = status
|
||||
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("updateStatus(%q)", escapeJS(status)))
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProgress shows a progress bar (0-100)
|
||||
func (ui *WebViewUI) UpdateProgress(percent int, description string) {
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("updateProgress(%d, %q)", percent, escapeJS(description)))
|
||||
})
|
||||
}
|
||||
|
||||
// ShowSuccess displays a success message
|
||||
func (ui *WebViewUI) ShowSuccess(message string) {
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("showMessage('success', %q)", escapeJS(message)))
|
||||
})
|
||||
}
|
||||
|
||||
// ShowError displays an error message
|
||||
func (ui *WebViewUI) ShowError(message string) {
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("showMessage('error', %q)", escapeJS(message)))
|
||||
})
|
||||
}
|
||||
|
||||
// ShowWarning displays a warning message
|
||||
func (ui *WebViewUI) ShowWarning(message string) {
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("showMessage('warning', %q)", escapeJS(message)))
|
||||
})
|
||||
}
|
||||
|
||||
// ShowInfo displays an info message
|
||||
func (ui *WebViewUI) ShowInfo(message string) {
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("showMessage('info', %q)", escapeJS(message)))
|
||||
})
|
||||
}
|
||||
|
||||
// WaitForKeypress waits for user acknowledgment
|
||||
// For WebView, we just pause briefly to let the user see the message
|
||||
func (ui *WebViewUI) WaitForKeypress(prompt string) {
|
||||
ui.UpdateStatus(prompt)
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
// ShowElapsedTime shows how long the operation took
|
||||
func (ui *WebViewUI) ShowElapsedTime() {
|
||||
elapsed := time.Since(ui.startTime).Seconds()
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Eval(fmt.Sprintf("showElapsed(%f)", elapsed))
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the WebView event loop (blocking)
|
||||
func (ui *WebViewUI) Run() {
|
||||
ui.w.Run()
|
||||
}
|
||||
|
||||
// Close cleans up WebView resources
|
||||
func (ui *WebViewUI) Close() {
|
||||
ui.w.Destroy()
|
||||
}
|
||||
|
||||
// escapeJS escapes a string for safe use in JavaScript
|
||||
func escapeJS(s string) string {
|
||||
// The %q format in Sprintf handles most escaping, but we use it for the whole string
|
||||
// So we just return the string as-is since Sprintf("%q", ...) will handle it
|
||||
return s
|
||||
}
|
||||
@@ -32,10 +32,10 @@ type InstallerUpdateInfo struct {
|
||||
type Updater struct {
|
||||
serverURL string
|
||||
httpClient *http.Client
|
||||
ui *UI
|
||||
ui UIInterface
|
||||
}
|
||||
|
||||
func NewUpdater(serverURL string, ui *UI) *Updater {
|
||||
func NewUpdater(serverURL string, ui UIInterface) *Updater {
|
||||
return &Updater{
|
||||
serverURL: serverURL,
|
||||
httpClient: &http.Client{
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
"CompanyName": "Eagle0",
|
||||
"FileDescription": "Eagle0 Installer",
|
||||
"FileVersion": "1.0.0.0",
|
||||
"InternalName": "EagleInstaller",
|
||||
"InternalName": "Eagle0",
|
||||
"LegalCopyright": "Copyright (c) 2024 Eagle0",
|
||||
"LegalTrademarks": "",
|
||||
"OriginalFilename": "EagleInstaller.exe",
|
||||
"OriginalFilename": "Eagle0.exe",
|
||||
"PrivateBuild": "",
|
||||
"ProductName": "Eagle0",
|
||||
"ProductVersion": "1.0.0.0",
|
||||
|
||||
@@ -157,7 +157,10 @@ message AddSettingsRequest {
|
||||
repeated AddSettingsKeyValue settings = 1;
|
||||
}
|
||||
|
||||
message AddSettingsResponse {}
|
||||
message AddSettingsResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2; // Only set if success is false
|
||||
}
|
||||
|
||||
message AddSettingsKeyValue {
|
||||
string key = 1;
|
||||
|
||||
@@ -2558,6 +2558,7 @@ ranger/male/000007.996945201.png
|
||||
### fixed
|
||||
fixed/aethelred_the_unready.png
|
||||
fixed/alakanda.png
|
||||
fixed/aldric_the_overlooked.png
|
||||
fixed/bergil.png
|
||||
fixed/bob_brokenbow.png
|
||||
fixed/bregos_fyar.png
|
||||
@@ -2565,39 +2566,38 @@ fixed/brewster.png
|
||||
fixed/bridget.png
|
||||
fixed/bromus.png
|
||||
fixed/cardinal_richeliu.png
|
||||
fixed/darth_plagueis.png
|
||||
fixed/david_duckplucker.png
|
||||
fixed/deckard_cain.png
|
||||
fixed/eagle.png
|
||||
fixed/eagle_with_hat.png
|
||||
fixed/elena_fyar.png
|
||||
fixed/elzix.png
|
||||
fixed/flea.png
|
||||
fixed/generic.png
|
||||
fixed/geoffrey_the_bozo.png
|
||||
fixed/hedrick_the_hedge_merchant.png
|
||||
fixed/ikhaan_tarn.png
|
||||
fixed/ironmaw.png
|
||||
fixed/jake.png
|
||||
fixed/jeb.png
|
||||
fixed/jesse_the_body_ventura.png
|
||||
fixed/john_ranil.png
|
||||
fixed/kriss_kross.png
|
||||
fixed/mack_daddy.png
|
||||
fixed/karvax_the_fleshless.png
|
||||
fixed/mad_mal.png
|
||||
fixed/magnus_the_boulder_varnok.png
|
||||
fixed/marty_mcpolitics.png
|
||||
fixed/mei_shen.png
|
||||
fixed/mesrop_mashtots.png
|
||||
fixed/norfolk.png
|
||||
fixed/old_marek_the_learned.png
|
||||
fixed/rabbit_spearer.png
|
||||
fixed/randall_galor.png
|
||||
fixed/ringo.png
|
||||
fixed/rod_rustyhelm.png
|
||||
fixed/roger.png
|
||||
fixed/sadar_rakon.png
|
||||
fixed/sam_swordthatsbent.png
|
||||
fixed/shu_lien.png
|
||||
fixed/skeletor.png
|
||||
fixed/trap_jaw.png
|
||||
fixed/silvio_the_smooth.png
|
||||
fixed/the_tumbler.png
|
||||
fixed/tomlin_the_tuneful.png
|
||||
fixed/turnil.png
|
||||
fixed/ul.png
|
||||
fixed/voros_the_undying.png
|
||||
fixed/zander_the_restless.png
|
||||
### mage
|
||||
mage/female/000002.3495282493.png
|
||||
mage/female/000003.1000321804.png
|
||||
|
||||
|
@@ -13,29 +13,29 @@ Bromus 25 65 60 45 97 292 39 98 81 66 60 mage FALSE fixed/bromus.png male Bromu
|
||||
Turnil 75 74 80 80 90 399 95 70 72 92 80 ranger FALSE fixed/turnil.png male Turnil was once a cleric, exploring the lands to the east, healing the sick and fighting evil. Eventually he became convinced that he could do more good fighting evil indirectly, becoming a ranger and using his stealth abilities to get others to avoid fighting. Now he serves wherever he thinks he can do the most good for the people. stern, battle-hardened, a survivor
|
||||
Bob Brokenbow 38 79 77 23 28 245 55 20 38 71 77 ranger FALSE fixed/bob_brokenbow.png male Bob Brokenbow is a competent scout, uninspiring but loyal. He got his nickname from his habit of somehow breaking all his bows, no matter how strong the wood. This is especially annoying to his allies, since the bow is the only weapon he's any good at using. reliable, uninspiring, loyal
|
||||
Rod Rustyhelm 40 80 75 25 5 225 80 75 88 91 75 engineer FALSE fixed/rod_rustyhelm.png male Rod Rustyhelm is one of the most dim-witted heroes in all the land. He does have occasional moments of "inspiration," where he comes up with some ridiculously hare-brained idea and decides it's genius, and won't shut up about it. Still, he does have some skill with weapons and can be trusted to repair a bridge or even build a ballista, when needed. an idiot, eager, mostly harmless
|
||||
Kriss Kross 95 75 95 80 60 405 91 98 85 63 95 noProfession FALSE fixed/kriss_kross.png other Kriss Kross is a relic from another age; most people have either forgotten about them, or wish they had. Don't try to compare them to another bad little fad. They'll give you something that you never had. Kriss Kross'll make you Jump Jump. likely to make you jump jump, musical, dated
|
||||
The Tumbler 95 75 95 80 60 405 91 98 85 63 95 noProfession FALSE fixed/the_tumbler.png other The Tumbler is an acrobat from a bygone era of entertainment. Most people have either forgotten about them or wish they had. They insist their act is timeless, though audiences tend to disagree. Still, their agility is undeniable, and they can be surprisingly useful in a fight. acrobatic, outdated, persistent
|
||||
Elena Fyar 75 90 85 85 90 425 82 86 71 91 85 paladin TRUE fixed/elena_fyar.png female Champions of Justice Elena Fyar is the daughter of the King and was once betrothed to his right-hand man, Shardok Ikhaan Tarn, but eventually refused him. Many at court blamed her for Tarn's betrayal, and she was forced to leave the court. She now commands her own faction. In truth, she was merely the first to see Tarn's unbridled ego and ambition, and none heeded her warnings. She is proud, but also hopes for eventual reconciliation with her father. proud, noble, formerly betrothed to Ikhaan Tarn, daughter of King Bregos Fyar
|
||||
Geoffrey the Bozo 95 80 75 85 27 362 2 3 49 1 75 noProfession FALSE fixed/geoffrey_the_bozo.png male Geoffrey the Bozo is a mysterious stranger. No one takes him seriously, since both his actions and his manner of speech are ridiculous. He's untrustworthy, unambitious, and a coward. He's therefore always underestimated. But don't be fooled; he's surprisingly strong, pretty good in a fight, and overall a very useful companion to have around. He's a nightmare in your dreams! surprisingly strong, useful, mysterious
|
||||
Æthelred the Unready 65 40 85 25 30 245 78 61 27 15 85 engineer FALSE fixed/aethelred_the_unready.png male Æthelred the Unready was once left in charge of a province, a charge for which he was utterly unsuited, and he did a terrible job. That earned him his nickname. Still, he's proven himself capable in the war, helping to repair battle damage in impoverished towns and even helping out in a battle now and then. ever-present, useful in a pinch, unwanted
|
||||
Randall Galor 96 85 100 71 63 415 98 90 47 95 100 engineer TRUE fixed/randall_galor.png male The Bulwark Brotherhood Randall Galor is a remarkable man. He's both a warrior skilled in personal combat, and a capable engineer. He served as an engineer in the King's army for several years, but went rogue when the civil war broke out. He believes that by fortifying his territory and by building mighty catapults and trebuchets to batter down the defenses of others, he's sure to emerge victorious. a warrior, a mechanic, remarkable
|
||||
Jesse "The Body" Ventura 100 85 98 91 72 446 23 97 86 91 98 champion TRUE fixed/jesse_the_body_ventura.png male We Ain't Got Time to Bleed Jesse "The Body" Ventura has tried a few routes to power. His comical, yet effective, wrestling techniques make him nearly unstoppable in single combat, but because he could never defer to another's authority, he never served either the King nor the Eagle. Now he's seeking a political path to power, but he's still not afraid to rumble when needed. In addition to his brute strength, his bald head and ridiculous mustache make him greatly feared. a wrestler, a politician, mustachioed
|
||||
Ringo 37 71 60 71 80 319 91 45 79 71 60 noProfession FALSE fixed/ringo.png male Ringo gave up a promising career as a beautician to become a drummer. He travels the land, telling jokes and playing the drums for the entertainment of children everywhere. He's not sure how he ended up involved in this war. a drummer, a beautician, affable
|
||||
Darth Plagueis 48 72 94 79 97 390 17 99 56 91 90 necromancer TRUE fixed/darth_plagueis.png male The Sith Lords Darth Plagueis tried to become so powerful that he could prevent the people he cared about from dying. That didn't work, so instead he became a necromancer. He is an equal opportunity practitioner of the dark arts. He'll reanimate his brother, his enemy, or the ex-girlfriends of his followers with equal passion. evil, power-hungry, terrifying
|
||||
Deckard Cain 17 22 60 60 93 252 95 12 65 95 60 noProfession FALSE fixed/deckard_cain.png male Deckard Cain is highly learned, and won't hesitate to let you know. Try as you might to get away from his constant blabbering, he'll keep insisting that you stay a while and listen. In fact, he says that constantly: "Stay a while, and listen." likely to ask you to stay a while and listen, learned, talkative
|
||||
Trap Jaw 99 91 98 33 37 358 17 87 41 63 98 engineer FALSE fixed/trap_jaw.png male Trap Jaw was an engineer in Skeletor's employ, before Skeletor got angry and destroyed his jaw and one of his arms. Trap Jaw was able to replace the missing parts with machinery. He doesn't seem at all intelligent, yet he does come up with clever contraptions for war. No one knows why he is blue. foolish, disloyal, metal-eating
|
||||
Jeb! 56 43 61 8 81 249 62 89 12 14 61 noProfession FALSE fixed/jeb.png male Jeb is a low-energy politician, and basically a loser. He had some surprising successes earlier in his career, and some thought he was destined for great power, like his father and brother before him. When he made a move for power, though, it turned out that no one wanted him, and it turns out his father and brother were not that great, either. Now he is adrift, willing to serve the right leader but noticed by almost no one. unwanted, low-energy, a loser
|
||||
Roger the Shrubber 51 95 80 85 91 402 92 15 73 71 80 ranger TRUE fixed/roger.png male Traveling Shrubbers Anonymous Roger the Shrubber is a traveling salesman, who carries shrubberies and the tools for planting and maintaining shrubberies in this cart. He is under considerable economic stress at this period in history. He disapproves of saying "ni" to old ladies. cart-riding, a traveling salesmen, under considerable economic stress at this period in history
|
||||
Magnus "The Boulder" Varnok 100 85 98 91 72 446 23 97 86 91 98 champion TRUE fixed/magnus_the_boulder_varnok.png male The Iron Fist Confederacy Magnus "The Boulder" Varnok rose to fame in the fighting pits, where his theatrical style and booming voice made him a crowd favorite. Unsatisfied with mere athletic glory, he turned to politics, believing that if he could command an arena, he could command a province. He defers to no one, and his bald head and magnificent mustache are known throughout the land. a pit fighter, a politician, mustachioed
|
||||
Tomlin the Tuneful 37 71 60 71 80 319 91 45 79 71 60 noProfession FALSE fixed/tomlin_the_tuneful.png male Tomlin abandoned a promising apprenticeship as a cobbler to pursue his true passion: percussion. He wanders the land with his drums, entertaining children and lifting spirits wherever he goes. He's not sure how he got caught up in this war, but he's determined to make the best of it. a drummer, formerly a cobbler, good-natured
|
||||
Voros the Undying 48 72 94 79 97 390 17 99 56 91 90 necromancer TRUE fixed/voros_the_undying.png male The Ashen Circle Voros was once a healer who became obsessed with conquering death itself. When his beloved died despite his efforts, he turned to forbidden arts, reasoning that if he could not prevent death, he would simply reverse it. Now he leads a cult of necromancers who believe that the boundary between life and death is merely an inconvenience. obsessive, grief-stricken, terrifying
|
||||
Old Marek the Learned 17 22 60 60 93 252 95 12 65 95 60 noProfession FALSE fixed/old_marek_the_learned.png male Old Marek has forgotten more than most scholars will ever know, and he's eager to share what he remembers with anyone who will listen. Unfortunately, he has difficulty telling when his audience has lost interest, and his lectures can stretch on for hours. He has a habit of saying "Now hold on, you'll want to hear this" just as you're trying to escape. Still, his knowledge has proven invaluable to those patient enough to hear him out. long-winded, knowledgeable, oblivious
|
||||
Ironmaw 99 91 98 33 37 358 17 87 41 63 98 engineer FALSE fixed/ironmaw.png male Ironmaw was an engineer who lost his jaw and arm in a siege gone wrong. Rather than accept his fate, he built mechanical replacements from scavenged metal. He's not particularly bright, but he has an intuitive genius for war machines. No one knows why his skin has a grayish-blue pallor, and he refuses to discuss it. resourceful, dim-witted, mechanically gifted
|
||||
Aldric the Overlooked 56 43 61 8 81 249 62 89 12 14 61 noProfession FALSE fixed/aldric_the_overlooked.png male Aldric comes from a prestigious family of rulers, and everyone assumed he would follow in their footsteps. When he finally made his bid for power, however, no one rallied to his cause. It turned out that his family's reputation had soured, and Aldric himself inspired no one. Now he drifts from faction to faction, hoping someone will give him a chance, but he is noticed by almost no one. uninspiring, from a famous family, a disappointment
|
||||
Hedrick the Hedge-Merchant 51 95 80 85 91 402 92 15 73 71 80 ranger TRUE fixed/hedrick_the_hedge_merchant.png male The Verdant Fellowship Hedrick travels the land selling decorative shrubberies from his cart, along with the tools needed to plant and maintain them. Business has been difficult during the war, and he's grown increasingly desperate. He has strong opinions about proper manners and detests rudeness toward the elderly. a traveling salesman, polite, economically stressed
|
||||
Rabbit Spearer 18 23 60 3 4 108 73 6 27 81 60 noProfession FALSE fixed/rabbit_spearer.png male Rabbit Spearer is the absolute worst. Everyone hates him, but sometimes he can talk his way into someone's retinue just as a warm body. He's quite stupid, and his strength and agility are not much better. the worst, hated, an idiot
|
||||
Norfolk 81 71 87 93 73 405 30 98 92 70 87 paladin TRUE fixed/norfolk.png male Duchy of Norfolk The Duke of Norfolk is a puzzling presence in this environment, since there is no place called Norfolk here. Nonetheless, he managed to rise to power, leading a faction of discontents. a duke, unexplained, unremarkable
|
||||
Elzix 73 85 91 86 68 403 41 58 99 96 91 engineer FALSE fixed/elzix.png male Elzix has been overlooked his whole life, but he's okay with that. He tends to settle down in one place and just quietly do the work, often making outrageous and amazing artifacts that he'll let adventurers gamble for. He's a lot of fun at parties. affable, honest, clever
|
||||
Ul 71 52 92 81 95 391 87 70 65 88 92 paladin FALSE fixed/ul.png male Ul is an affable shaman who one day dreams of becoming a Far Seer. No one knows where he came from, or how an orc like him got to the Kingdom, or what an orc is, anyway, but he doesn't let that bother him. He's just here to kick butt and heal wounds. affable, a shaman, an orc
|
||||
Ul 71 52 92 81 95 391 87 70 65 88 92 paladin FALSE fixed/ul.png male Ul is an affable shaman who dreams of one day mastering the deeper mysteries of his craft. No one knows where he came from, or how a green-skinned tusked warrior like him arrived in the Kingdom, but he doesn't let that bother him. He's just here to fight evil and heal the wounded. affable, a shaman, an outsider
|
||||
Brewster 82 32 87 93 56 350 79 12 88 71 87 noProfession FALSE fixed/brewster.png male Brewster is a man of no special talents, but generally a decent guy to have around. He travels the land, but nobody knows why. Nobody had even heard of him before the war began. mysterious, friendly, talkative
|
||||
Mesrop Mashtots 36 90 60 75 98 359 95 81 50 81 60 ranger FALSE fixed/mesrop_mashtots.png male Mesrop Mashtots is a great scholar, who has invented many alphabets, because why stop with just one? He's traveled to far-away lands and lived to tell the tale. He came back to find the Kingdom in civil war, and has not yet chosen his side. scholarly, an inventor of alphabets, well-traveled
|
||||
David Duckplucker 21 20 60 14 7 122 80 12 60 81 60 ranger FALSE fixed/david_duckplucker.png male David Duckplucker is an idiot, but he throws himself into every project with great gusto. His friends started calling him Duckplucker as a challenge, but try as he might, he has never successfully plucked a duck. You could do worse than to hire David Duckplucker, but you couldn't do much worse. an idiot, eager, incapable of plucking a duck
|
||||
Pea-Brained Jake-o the Peacock Floofio Majestico 91 28 78 100 23 320 32 51 100 14 78 ranger FALSE fixed/jake.png male Pea-Brained Jake-o the Peacock Floofio Majestico is one of the most beloved heroes in all the land. He would never hurt anyone on purpose, and is quite clumsy, yet somehow a skilled hunter. Most of all, he's extraordinarily handsome, with a lush red mane that is the envy of all the world. handsome, lovable, a doofus
|
||||
Flea 85 95 90 96 82 448 45 91 93 74 90 champion TRUE fixed/flea.png male The Syncopated Sanctum Flea is a crazy bassist. Soft spoken with a broken jaw, step outside but not to brawl. Fully loaded we got snacks and supplies. I don't ever wanna feel like I did that day, take me to the place I love, take me all the way. a bassist, musical, crazy
|
||||
Daddy Mac 92 80 90 83 71 416 61 43 81 95 90 noProfession FALSE fixed/mack_daddy.png male Daddy Mac is a traveling musician. He's well liked everywhere he goes, especially by the ladies. No one knows exactly what his motivations are, but one thing's for sure: The Daddy Mac'll make you Jump Jump. He'll make you bump bump, wiggle and shake your rump. useful, a ladies' man, likely to make you jump jump
|
||||
Zander the Restless 85 95 90 96 82 448 45 91 93 74 90 champion TRUE fixed/zander_the_restless.png male The Syncopated Sanctum Zander is a wild-eyed musician who plays the lute with a ferocity no one has seen before. He's soft-spoken until he performs, at which point he becomes a whirlwind of energy. He founded a commune of like-minded artists who believe that music can change the world, or at least make it more bearable. a musician, intense, unpredictable
|
||||
Silvio the Smooth 92 80 90 83 71 416 61 43 81 95 90 noProfession FALSE fixed/silvio_the_smooth.png male Silvio is a traveling entertainer known for his charm and his way with words. He's popular wherever he goes, especially among the ladies. No one is quite sure what his goals are, but he's pleasant company and surprisingly handy in a scrap. charming, a ladies' man, easygoing
|
||||
Bridget 83 91 92 98 100 464 62 97 92 100 92 mage TRUE fixed/bridget.png female Vengeance Bridget's teachers and fellow pupils at the Academy constantly underestimated her, and she grew disillusioned. When the Eagle arrived, she jumped at the chance to join his rebellion, and she learned much from him, becoming one of the most powerful wizards in the land. She earned great renown when, in battle against a superior force from the King's army, she initially escaped capture, swam across a river, and single-handedly began raining lightning down on the enemy camp. She was eventually caught, and lost faith when the Eagle failed to rescue or ransom her. After escaping, she formed her own faction, with loyalties to no one. astonishingly powerful, bold, ambitious
|
||||
Marty McPolitics 24 28 77 12 98 239 3 91 67 8 77 noProfession FALSE fixed/marty_mcpolitics.png male Marty McPolitics is smart, but no one likes him or trusts him, and for good reason. He'd stab a friend in the back for the smallest advantage, and he's no good in a fight. a loser, fickle, ambitious
|
||||
Skeletor 95 70 95 100 60 420 2 100 40 30 95 necromancer TRUE fixed/skeletor.png male The Evil Warriors Many stories circulate about Skeletor's origins. Is he the uncle of his arch-nemesis, disfigured after a great battle? Is he an invader from another dimension? Why is he blue, and how did he get so muscle-bound when he seems to be lazy and do everything with magic? But many follow him anyway, for he is enormously powerful and lusts for even more. power-hungry, evil, witty
|
||||
Shu Lien 86 100 92 71 89 438 98 37 45 99 92 champion TRUE fixed/shu_lien.png female Merchant Protection League Shu Lien is a skilled martial artist. She possess now magical or supernatural abilities; her abilities come from her own training and raw physical power. She once fought in the great wars, but now she runs her own trading company, using her talents to protect her caravans. For the right just cause, though, she might be persuaded to rejoin the fight. noble, sad, strict
|
||||
Karvax the Fleshless 95 70 95 100 60 420 2 100 40 30 95 necromancer TRUE fixed/karvax_the_fleshless.png male The Hollow Throne No one knows what Karvax looked like before the accident that stripped the flesh from his skull, leaving only bone and sinew held together by dark magic. Some say he was a prince; others say he was always a monster. Whatever the truth, he now commands legions of followers drawn to his raw power and his promise that they too can transcend their mortal limitations. power-hungry, mysterious, darkly witty
|
||||
Mei Shen 86 100 92 71 89 438 98 37 45 99 92 champion TRUE fixed/mei_shen.png female The Jade Caravan Mei Shen is a martial artist of extraordinary skill, trained from childhood in an ancient discipline. She possesses no magical abilities; her prowess comes entirely from decades of rigorous practice. She once served as a bodyguard to nobles, but now runs her own trading company, personally defending her caravans from bandits. For the right cause, she might be persuaded to fight again. disciplined, melancholy, honorable
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 5.
|
@@ -10,10 +10,12 @@ object ApiKeys {
|
||||
// Environment variable names
|
||||
private val openAIEnvVar = "OPENAI_API_KEY"
|
||||
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
|
||||
private val geminiEnvVar = "GEMINI_API_KEY"
|
||||
|
||||
// File key names
|
||||
private val openAIKeyName = "openai_api_key"
|
||||
private val anthropicKeyName = "anthropic_api_key"
|
||||
private val geminiKeyName = "gemini_api_key"
|
||||
|
||||
private def readDictionary(): Map[String, String] =
|
||||
Using(scala.io.Source.fromFile(apiKeyFilePath)) { source =>
|
||||
@@ -54,4 +56,6 @@ object ApiKeys {
|
||||
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
|
||||
|
||||
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
|
||||
|
||||
lazy val gemini: String = getKey(geminiKeyName, geminiEnvVar)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,23 @@ scala_library(
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "gemini_service_impl",
|
||||
srcs = ["GeminiServiceImpl.scala"],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
deps = [
|
||||
":api_keys",
|
||||
":external_text_generation_service_impl",
|
||||
":rate_limits",
|
||||
":streaming_text_results",
|
||||
"@maven//:org_json4s_json4s_ast_3",
|
||||
"@maven//:org_json4s_json4s_core_3",
|
||||
"@maven//:org_json4s_json4s_native_3",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "external_text_generation_caller",
|
||||
srcs = ["ExternalTextGenerationCaller.scala"],
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package net.eagle0.common.llm_integration
|
||||
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpRequest.BodyPublishers
|
||||
import java.net.URI
|
||||
import java.time.Duration
|
||||
import java.util.function.Consumer
|
||||
|
||||
import org.json4s.{DefaultFormats, JArray, JObject, JString}
|
||||
import org.json4s.jvalue2extractable
|
||||
import org.json4s.jvalue2monadic
|
||||
import org.json4s.native.{Json, Serialization}
|
||||
|
||||
object GeminiServiceImpl {
|
||||
val defaultModel: String = "gemini-2.5-flash-lite"
|
||||
|
||||
private val apiKey = ApiKeys.gemini
|
||||
private val baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
private def requestUrl(modelName: String): String =
|
||||
s"$baseURL/$modelName:streamGenerateContent?alt=sse&key=$apiKey"
|
||||
|
||||
private def contentsFor(
|
||||
inputText: String,
|
||||
partialCompletion: Option[String]
|
||||
): Vector[Map[String, Any]] = {
|
||||
val userMessage = Map(
|
||||
"role" -> "user",
|
||||
"parts" -> Vector(Map("text" -> inputText))
|
||||
)
|
||||
partialCompletion match {
|
||||
case Some(partial) =>
|
||||
Vector(
|
||||
userMessage,
|
||||
Map(
|
||||
"role" -> "model",
|
||||
"parts" -> Vector(Map("text" -> partial))
|
||||
)
|
||||
)
|
||||
case None => Vector(userMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private def baseRequest(
|
||||
modelName: String,
|
||||
timeoutSeconds: Int
|
||||
): HttpRequest.Builder =
|
||||
HttpRequest
|
||||
.newBuilder()
|
||||
.uri(URI.create(requestUrl(modelName)))
|
||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.header("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
class GeminiServiceImpl(
|
||||
val timeoutSeconds: Int = 10,
|
||||
val defaultModelName: String = GeminiServiceImpl.defaultModel
|
||||
) extends ExternalTextGenerationServiceImpl {
|
||||
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
|
||||
|
||||
private def requestDictionary(
|
||||
inputText: String,
|
||||
partialCompletion: Option[String]
|
||||
): Map[String, Any] =
|
||||
Map(
|
||||
"contents" -> GeminiServiceImpl.contentsFor(inputText, partialCompletion)
|
||||
)
|
||||
|
||||
override def makeRequest(
|
||||
inputText: String,
|
||||
partialCompletion: Option[String]
|
||||
): HttpRequest =
|
||||
GeminiServiceImpl
|
||||
.baseRequest(modelName = defaultModelName, timeoutSeconds = timeoutSeconds)
|
||||
.POST(
|
||||
BodyPublishers.ofString(
|
||||
Serialization.write(
|
||||
requestDictionary(
|
||||
inputText = inputText,
|
||||
partialCompletion = partialCompletion
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
private class GeminiStringConsumer(
|
||||
streamingConsumer: Consumer[StreamingTextResults]
|
||||
) extends Consumer[String] {
|
||||
private val json = new Json(DefaultFormats)
|
||||
// Gemini doesn't provide a stream ID in the same way as OpenAI/Claude,
|
||||
// so we generate one based on the request
|
||||
private val streamId = java.util.UUID.randomUUID().toString
|
||||
|
||||
override def accept(t: String): Unit = {
|
||||
val parsedJson =
|
||||
try
|
||||
json.parse(t)
|
||||
catch {
|
||||
case _: org.json4s.ParserUtil.ParseException =>
|
||||
// Skip malformed JSON chunks
|
||||
return
|
||||
}
|
||||
|
||||
// Check for errors first
|
||||
(parsedJson \ "error") match {
|
||||
case JObject(errorFields) if errorFields.nonEmpty =>
|
||||
val errorMessage = (parsedJson \ "error" \ "message").extractOpt[String].getOrElse("Unknown error")
|
||||
println(s"Gemini API error: $errorMessage")
|
||||
return
|
||||
case _ => // No error, continue processing
|
||||
}
|
||||
|
||||
// Extract candidates array
|
||||
val candidates = (parsedJson \ "candidates") match {
|
||||
case JArray(arr) => arr
|
||||
case _ => return // No candidates in this chunk
|
||||
}
|
||||
|
||||
if candidates.isEmpty then return
|
||||
|
||||
val firstCandidate = candidates.head.asInstanceOf[JObject]
|
||||
|
||||
// Check finish reason
|
||||
val finishReason = (firstCandidate \ "finishReason") match {
|
||||
case JString(reason) => Some(reason)
|
||||
case _ => None
|
||||
}
|
||||
|
||||
val completed = finishReason.exists(r => r == "STOP" || r == "MAX_TOKENS" || r == "SAFETY")
|
||||
|
||||
// Extract text content
|
||||
val textContent = for {
|
||||
content <- (firstCandidate \ "content").toOption
|
||||
parts <- (content \ "parts") match {
|
||||
case JArray(arr) => Some(arr)
|
||||
case _ => None
|
||||
}
|
||||
if parts.nonEmpty
|
||||
firstPart = parts.head.asInstanceOf[JObject]
|
||||
text <- (firstPart \ "text") match {
|
||||
case JString(s) => Some(s)
|
||||
case _ => None
|
||||
}
|
||||
} yield text
|
||||
|
||||
textContent match {
|
||||
case Some(text) =>
|
||||
streamingConsumer.accept(
|
||||
StreamingTextResults(
|
||||
streamId = streamId,
|
||||
value = text,
|
||||
completed = completed
|
||||
)
|
||||
)
|
||||
case None =>
|
||||
// No text content but might be completed
|
||||
if completed then
|
||||
streamingConsumer.accept(
|
||||
StreamingTextResults(
|
||||
streamId = streamId,
|
||||
value = "",
|
||||
completed = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override def stringConsumer(
|
||||
streamingConsumer: Consumer[StreamingTextResults]
|
||||
): Consumer[String] = new GeminiStringConsumer(streamingConsumer)
|
||||
|
||||
// Gemini rate limit headers are different from OpenAI/Anthropic
|
||||
// For now, return None as rate limit tracking isn't critical
|
||||
override def rateLimitsFrom(
|
||||
headers: Map[String, Vector[String]]
|
||||
): Option[RateLimits] = None
|
||||
}
|
||||
+16
-6
@@ -19,14 +19,24 @@ object OpenAIChatCompletionsServiceImpl {
|
||||
private val baseURL = new URL("https://api.openai.com/v1/chat/completions")
|
||||
private val temperature: Double = 1.0
|
||||
|
||||
// Non-reasoning models have -mini or -nano suffix; all others support reasoning_effort
|
||||
private val nonReasoningSuffixes = Set("-mini", "-nano")
|
||||
|
||||
private def isReasoningModel(modelName: String): Boolean =
|
||||
!nonReasoningSuffixes.exists(suffix => modelName.contains(suffix))
|
||||
|
||||
private def dictionaryFor(
|
||||
modelName: String
|
||||
): Map[String, Any] = Map(
|
||||
"model" -> modelName,
|
||||
"temperature" -> temperature,
|
||||
"stream" -> true,
|
||||
"reasoning_effort" -> "none"
|
||||
)
|
||||
): Map[String, Any] = {
|
||||
val baseParams = Map(
|
||||
"model" -> modelName,
|
||||
"temperature" -> temperature,
|
||||
"stream" -> true
|
||||
)
|
||||
// Only include reasoning_effort for reasoning models (o1, o3, etc.)
|
||||
if isReasoningModel(modelName) then baseParams + ("reasoning_effort" -> "none")
|
||||
else baseParams
|
||||
}
|
||||
|
||||
private def messageVector(
|
||||
inputText: String,
|
||||
|
||||
@@ -52,11 +52,17 @@ class OkHttpSseListener(messageDataConsumer: Consumer[String]) extends EventSour
|
||||
response: Response
|
||||
): Unit =
|
||||
if !future.isDone then {
|
||||
val errorMessage = if t != null then t.getMessage else "unknown error"
|
||||
if response != null then {
|
||||
println(s"SSE failure with response code ${response.code()}: ${t.getMessage}")
|
||||
println(s"SSE failure with response code ${response.code()}: $errorMessage")
|
||||
} else {
|
||||
println(s"SSE failure: ${t.getMessage}")
|
||||
println(s"SSE failure: $errorMessage")
|
||||
}
|
||||
val _ = future.completeExceptionally(t)
|
||||
// OkHttp can pass null throwable for HTTP error responses
|
||||
val exception =
|
||||
if t != null then t
|
||||
else
|
||||
new RuntimeException(s"SSE failed with response code ${Option(response).map(_.code()).getOrElse("unknown")}")
|
||||
val _ = future.completeExceptionally(exception)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ scala_binary(
|
||||
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence:s3_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
|
||||
|
||||
@@ -12,7 +12,7 @@ import net.eagle0.eagle.api.auth.auth.AuthGrpc
|
||||
import net.eagle0.eagle.api.eagle.EagleGrpc
|
||||
import net.eagle0.eagle.auth.{JwtService, OAuthService, OAuthServiceImpl, UserService, UserServiceImpl}
|
||||
import net.eagle0.eagle.service.*
|
||||
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
|
||||
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, S3Utils, SaveDirectory}
|
||||
|
||||
object Main {
|
||||
import ServerSetupHelpers.*
|
||||
@@ -25,8 +25,6 @@ object Main {
|
||||
)
|
||||
private val defaultShardokInterfaceRemoteAddress = "eagle0.net:443"
|
||||
|
||||
private val gptModelNameKey = Symbol("gptModelName")
|
||||
|
||||
private val serverBaseUrlKey = Symbol("serverBaseUrl")
|
||||
private val defaultServerBaseUrl = "https://prod.eagle0.net"
|
||||
|
||||
@@ -49,11 +47,6 @@ object Main {
|
||||
map ++ Map(shardokInterfaceRemoteAddressKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--gpt-model-name" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(gptModelNameKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--server-base-url" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(serverBaseUrlKey -> value),
|
||||
@@ -106,6 +99,10 @@ object Main {
|
||||
// Register shutdown hook to flush pending S3 saves on graceful shutdown
|
||||
AsyncS3Persister.registerShutdownHook()
|
||||
|
||||
// Eagerly initialize S3 client to avoid first-connection latency
|
||||
// (AWS SDK performs lazy initialization that can add 5+ seconds to first request)
|
||||
S3Utils.warmup()
|
||||
|
||||
val options = parsedOptions(args)
|
||||
SimpleTimedLogger.printLogger.logLine(s"Options: $options")
|
||||
|
||||
@@ -133,7 +130,6 @@ object Main {
|
||||
|
||||
val gamesManager = newGamesManager(
|
||||
shardokInterfaceAddress = shardokInterfaceAddress,
|
||||
gptModelName = options(gptModelNameKey),
|
||||
securityConfig = shardokSecurityConfig
|
||||
)
|
||||
gamesManager.begin()
|
||||
|
||||
@@ -932,7 +932,7 @@ object MidGameAIClient {
|
||||
actingProvinceId = ac.actingProvinceId,
|
||||
available = ac,
|
||||
selected = ReconSelected(
|
||||
actingHeroId = ac.availableHeroIds.maxBy(hid => gameState.heroes(hid).vigor),
|
||||
actingHeroId = ac.availableHeroIds.minBy(hid => HeroUtils.fatigue(gameState.heroes(hid))),
|
||||
targetProvinceId = targetProvinceId
|
||||
),
|
||||
reason = "selectedReconCommand"
|
||||
|
||||
@@ -207,10 +207,12 @@ scala_library(
|
||||
":generator_utilities",
|
||||
":hero_description_generator",
|
||||
":llm_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero:event_for_hero_backstory_trait",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
+7
-6
@@ -57,12 +57,11 @@ case class ChronicleUpdatePromptGenerator(
|
||||
private def introString(currentDate: Date): String = {
|
||||
val intro =
|
||||
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for
|
||||
|control of the kingdom. Write a $chronicleWordCount-word chronicle of the state of the war, as told by an
|
||||
|in-universe chronicler, who writes in the style and format of $chroniclerStyle (but without reproducing any
|
||||
|copyrighted material), as of ${GeneratorUtilities
|
||||
.monthNames(
|
||||
currentDate.month.value
|
||||
)} of the Year of the Realm ${currentDate.year}.""".stripMargin
|
||||
|control of the kingdom. Write a chronicle of the state of the war, as told by an in-universe chronicler, who
|
||||
|writes in the style and format of $chroniclerStyle (but without reproducing any copyrighted material), as of
|
||||
|${GeneratorUtilities.monthNames(currentDate.month.value)} of the Year of the Realm ${currentDate.year}.
|
||||
|IMPORTANT: Keep your response to approximately $chronicleWordCount words (about 1.5 pages). Be concise and
|
||||
|focus only on the most significant events.""".stripMargin
|
||||
.replaceAll("\n", " ")
|
||||
|
||||
val styleGuidance =
|
||||
@@ -197,6 +196,8 @@ case class ChronicleUpdatePromptGenerator(
|
||||
|---
|
||||
|THIRD SECTION
|
||||
|$factionText
|
||||
|---
|
||||
|REMINDER: Keep the chronicle to approximately $chronicleWordCount words. Be selective about which events to include - focus on the 2-3 most significant developments rather than cataloguing everything.
|
||||
|""".stripMargin
|
||||
}
|
||||
}
|
||||
|
||||
+78
-2
@@ -3,6 +3,7 @@ package net.eagle0.eagle.library.actions.llm_prompt_generators
|
||||
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult}
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.*
|
||||
import net.eagle0.eagle.HeroId
|
||||
|
||||
case class HandleCapturedHeroPleaPromptGenerator(
|
||||
@@ -24,6 +25,75 @@ case class HandleCapturedHeroPleaPromptGenerator(
|
||||
private val actingFactionLeader =
|
||||
gameState.heroes(actingFaction.factionHeadId)
|
||||
|
||||
private def relationshipContext(
|
||||
capturedHeroName: String,
|
||||
actingFactionLeaderName: String
|
||||
): String = {
|
||||
val possP =
|
||||
HeroDescriptionGenerator.possessivePronoun(capturedHero.pronounGender)
|
||||
|
||||
// Check if captured hero was ever in the acting faction (betrayal/defection)
|
||||
val wasInActingFaction = capturedHero.backstoryEvents.exists {
|
||||
case e: JoinedByInvitationBackstoryEvent =>
|
||||
e.previousFactionId == plea.actingFactionId
|
||||
case e: HeroDepartedBackstoryEvent =>
|
||||
e.departedFromFactionId == plea.actingFactionId
|
||||
case _ => false
|
||||
}
|
||||
|
||||
val betrayalText =
|
||||
if wasInActingFaction then
|
||||
s"$capturedHeroName once served in ${actingFaction.name} before leaving. $actingFactionLeaderName may view $possP departure as a betrayal."
|
||||
else ""
|
||||
|
||||
// Check if previously captured by the same faction
|
||||
val previousCaptureByActingFaction = capturedHero.backstoryEvents.exists {
|
||||
case e: CapturedHeroImprisonedBackstoryEvent =>
|
||||
e.capturingFactionId == plea.actingFactionId
|
||||
case e: CapturedHeroExiledBackstoryEvent =>
|
||||
e.capturingFactionId == plea.actingFactionId
|
||||
case e: CapturedHeroRecruitedBackstoryEvent =>
|
||||
e.capturingFactionId == plea.actingFactionId
|
||||
case e: CapturedHeroReturnedBackstoryEvent =>
|
||||
e.capturingFactionId == plea.actingFactionId
|
||||
case _ => false
|
||||
}
|
||||
|
||||
val previousCaptureText =
|
||||
if previousCaptureByActingFaction then
|
||||
s"This is not the first time $capturedHeroName has been captured by ${actingFaction.name}."
|
||||
else ""
|
||||
|
||||
// Check for prior battles against the acting faction
|
||||
val priorBattlesAgainstActingFaction = capturedHero.backstoryEvents.count {
|
||||
case e: FoughtInBattleBackstoryEvent =>
|
||||
e.enemyFactionIds.contains(plea.actingFactionId)
|
||||
case _ => false
|
||||
}
|
||||
|
||||
val priorBattlesText =
|
||||
if priorBattlesAgainstActingFaction > 2 then
|
||||
s"$capturedHeroName has fought against ${actingFaction.name} many times before. There is history between them."
|
||||
else if priorBattlesAgainstActingFaction > 0 then
|
||||
s"$capturedHeroName has fought against ${actingFaction.name} before."
|
||||
else ""
|
||||
|
||||
Vector(
|
||||
betrayalText,
|
||||
previousCaptureText,
|
||||
priorBattlesText
|
||||
).filter(_.nonEmpty).mkString(" ")
|
||||
}
|
||||
|
||||
private def personalityGuidance(capturedHeroName: String): String = {
|
||||
val words = capturedHero.personalityWords
|
||||
if words.isEmpty then ""
|
||||
else {
|
||||
val wordsText = GeneratorUtilities.conjunction(words).getOrElse("")
|
||||
s"$capturedHeroName is $wordsText - the message should reflect this personality."
|
||||
}
|
||||
}
|
||||
|
||||
def generate: TextGenerationResult = for {
|
||||
capturedHeroDescription <- HeroDescriptionGenerator.description(
|
||||
heroId = plea.capturedHeroId,
|
||||
@@ -70,6 +140,10 @@ case class HandleCapturedHeroPleaPromptGenerator(
|
||||
val executedHeroText =
|
||||
s"$actingFactionLeaderName has already executed $executedHeroNames."
|
||||
|
||||
val relationshipText = relationshipContext(capturedHeroName, actingFactionLeaderName)
|
||||
val personalityText = personalityGuidance(capturedHeroName)
|
||||
val relationshipBlock = if relationshipText.nonEmpty then s"\n$relationshipText\n" else ""
|
||||
|
||||
val inputText =
|
||||
s"""
|
||||
|$setup
|
||||
@@ -80,12 +154,14 @@ case class HandleCapturedHeroPleaPromptGenerator(
|
||||
|$imprisonedHeroText
|
||||
|$exiledHeroText
|
||||
|$executedHeroText
|
||||
|
|
||||
|$relationshipBlock
|
||||
|${subjP.capitalize} $isV undecided whether $subjP would join $actingFactionLeaderName, if asked.
|
||||
|
|
||||
|$capturedHeroDescription
|
||||
|
|
||||
|Write a message $capturedHeroName might say to $actingFactionLeaderName.
|
||||
|Write a message $capturedHeroName might say to $actingFactionLeaderName. $personalityText
|
||||
|The message might be defiant, pleading, negotiating, curious, resigned, bitter, hopeful, or sardonic depending on the character.
|
||||
|Avoid clichéd phrases about swift death or killing.
|
||||
|
|
||||
|${GeneratorUtilities.limitations}""".stripMargin
|
||||
|
||||
|
||||
@@ -3438,7 +3438,7 @@ scala_setting_library(
|
||||
name = "chronicle_word_count",
|
||||
setting_name = "ChronicleWordCount",
|
||||
setting_type = "Int",
|
||||
setting_value = "200",
|
||||
setting_value = "400",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__subpackages__",
|
||||
@@ -3563,3 +3563,55 @@ scala_setting_library(
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_setting_library(
|
||||
name = "llm_provider",
|
||||
setting_name = "LlmProvider",
|
||||
setting_type = "String",
|
||||
setting_value = "openai",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__subpackages__",
|
||||
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_setting_library(
|
||||
name = "openai_model_name",
|
||||
setting_name = "OpenAiModelName",
|
||||
setting_type = "String",
|
||||
setting_value = "gpt-5-mini",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__subpackages__",
|
||||
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_setting_library(
|
||||
name = "claude_model_name",
|
||||
setting_name = "ClaudeModelName",
|
||||
setting_type = "String",
|
||||
setting_value = "claude-sonnet-4-20250514",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__subpackages__",
|
||||
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_setting_library(
|
||||
name = "gemini_model_name",
|
||||
setting_name = "GeminiModelName",
|
||||
setting_type = "String",
|
||||
setting_value = "gemini-2.5-flash-lite",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__subpackages__",
|
||||
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -15,3 +15,11 @@ scala_library(
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "string_setting",
|
||||
srcs = ["StringSetting.scala"],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.eagle0.eagle.library.settings.base
|
||||
|
||||
class StringSetting(private var _stringValue: String) {
|
||||
def stringValue: String = _stringValue
|
||||
def setStringValue(newValue: String): Unit = _stringValue = newValue
|
||||
}
|
||||
@@ -77,6 +77,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:break_alliance_wisdom_xp",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:champion_training_bonus",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:chronicle_word_count",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:claude_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:control_weather_blizzard_duration_months",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:control_weather_drought_duration_months",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:control_weather_vigor_delta",
|
||||
@@ -117,6 +118,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:food_per_province_held_back",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:free_hero_move_vigor_cost",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:freed_prisoner_loyalty",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:gift_province_count_exponent",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:gold_cost_for_divine",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:gold_increase_per_economy",
|
||||
@@ -138,6 +140,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:jailbreak_success_base_chance",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:jailbreak_vigor_cost",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:jailbreak_wisdom_xp",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:llm_provider",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_decrease_per_discordance",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_discordance_threshold",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
|
||||
@@ -223,6 +226,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:months_recon_considered_recent",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:new_hero_chance",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:new_round_vigor_gain",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:openai_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:over_hero_cap_loyalty_delta",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:over_resource_limit_loss",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:per_development_resource_limit",
|
||||
@@ -316,5 +320,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings/base:double_setting",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings/base:int_setting",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings/base:string_setting",
|
||||
],
|
||||
)
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@ package net.eagle0.eagle.library.util.command_choice_helpers
|
||||
import net.eagle0.eagle.{FactionId, HeroId}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.ofType
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
|
||||
@@ -43,8 +44,8 @@ object AllianceOfferCommandSelector {
|
||||
selected = DiplomacySelected(
|
||||
selectedOption = DiplomacyOptionType.Alliance,
|
||||
targetFactionId = targetFactionId,
|
||||
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).maxBy { hid =>
|
||||
gameState.heroes(hid).vigor
|
||||
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).minBy { hid =>
|
||||
HeroUtils.fatigue(gameState.heroes(hid))
|
||||
}
|
||||
),
|
||||
reason = "chosenAllianceWithFactionCommand"
|
||||
|
||||
@@ -12,6 +12,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
|
||||
@@ -468,6 +469,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
|
||||
|
||||
+1
-1
@@ -657,7 +657,7 @@ object CommandChoiceHelpers {
|
||||
available = availableCommand,
|
||||
selected = {
|
||||
val selectedHeroId = availableCommand.availableHeroIds
|
||||
.maxBy(hid => gameState.heroes(hid).vigor)
|
||||
.minBy(hid => HeroUtils.fatigue(gameState.heroes(hid)))
|
||||
HandleRiotCrackDownSelected(
|
||||
heroId = selectedHeroId,
|
||||
battalionId = availableCommand.battalionIdsAvailable
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@ package net.eagle0.eagle.library.util.command_choice_helpers
|
||||
import net.eagle0.eagle.{FactionId, HeroId}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.ofType
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
|
||||
@@ -43,8 +44,8 @@ object TruceOfferCommandSelector {
|
||||
selected = DiplomacySelected(
|
||||
selectedOption = DiplomacyOptionType.Truce,
|
||||
targetFactionId = targetFactionId,
|
||||
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).maxBy { hid =>
|
||||
gameState.heroes(hid).vigor
|
||||
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).minBy { hid =>
|
||||
HeroUtils.fatigue(gameState.heroes(hid))
|
||||
}
|
||||
),
|
||||
reason = "chosenTruceWithFactionCommand"
|
||||
|
||||
@@ -31,6 +31,12 @@ scala_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/admin:game_admin_scala_grpc",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:gemini_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
|
||||
"//src/main/scala/net/eagle0/eagle/library:engine",
|
||||
@@ -43,6 +49,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
|
||||
"@maven//:com_thesamet_scalapb_scalapb_json4s_3",
|
||||
"@maven//:io_sentry_sentry",
|
||||
"@maven//:org_json4s_json4s_ast_3",
|
||||
"@maven//:org_json4s_json4s_core_3",
|
||||
"@maven//:org_json4s_json4s_native_3",
|
||||
@@ -215,6 +222,7 @@ scala_library(
|
||||
":llm_resolver",
|
||||
":llm_update_queuing_proxy",
|
||||
":llm_update_receiver",
|
||||
":missing_hero_name_recovery",
|
||||
":persisted_history",
|
||||
":post_results",
|
||||
":sync_response_observer",
|
||||
@@ -300,6 +308,24 @@ scala_library(
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "missing_hero_name_recovery",
|
||||
srcs = ["MissingHeroNameRecovery.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/service:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
|
||||
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "llm_resolver",
|
||||
srcs = ["LlmResolver.scala"],
|
||||
@@ -308,11 +334,12 @@ scala_library(
|
||||
],
|
||||
deps = [
|
||||
":llm_update_queuing_proxy",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:gemini_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
|
||||
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
|
||||
@@ -350,6 +377,10 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:swear_brotherhood_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_offer_message_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_resolution_message_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:claude_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:llm_provider",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:openai_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:generated_text_request_converter",
|
||||
"@maven//:io_sentry_sentry",
|
||||
@@ -363,8 +394,8 @@ scala_library(
|
||||
"//src/test/scala/net/eagle0/eagle/service:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -376,8 +407,8 @@ scala_library(
|
||||
],
|
||||
deps = [
|
||||
":llm_update_receiver",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -4,10 +4,19 @@ import java.io.{ByteArrayOutputStream, File}
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.{ZipEntry, ZipOutputStream}
|
||||
|
||||
import scala.concurrent.Future
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.{Failure, Success, Try}
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import io.grpc.stub.StreamObserver
|
||||
import io.sentry.Sentry
|
||||
import net.eagle0.common.llm_integration.{
|
||||
ClaudeServiceImpl,
|
||||
ExternalTextGenerationCaller,
|
||||
ExternalTextGenerationServiceImpl,
|
||||
GeminiServiceImpl,
|
||||
OpenAIChatCompletionsServiceImpl
|
||||
}
|
||||
import net.eagle0.common.SimpleTimedLogger
|
||||
import net.eagle0.eagle.*
|
||||
import net.eagle0.eagle.admin.game_admin.*
|
||||
@@ -281,13 +290,100 @@ class GameAdminServiceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
private val llmSettingKeys = Set("LlmProvider", "OpenAiModelName", "ClaudeModelName", "GeminiModelName")
|
||||
|
||||
private def validateLlmSettings(provider: String, modelName: String): Future[Either[String, Unit]] = {
|
||||
// Wrap service creation in Try to catch API key initialization errors
|
||||
val testServiceResult: Try[ExternalTextGenerationServiceImpl] = Try {
|
||||
provider.toLowerCase match {
|
||||
case "openai" => new OpenAIChatCompletionsServiceImpl(defaultModelName = modelName)
|
||||
case "claude" => new ClaudeServiceImpl(defaultModelName = modelName)
|
||||
case "gemini" => new GeminiServiceImpl(defaultModelName = modelName)
|
||||
case _ => throw new IllegalArgumentException(s"Unknown provider: $provider")
|
||||
}
|
||||
}
|
||||
|
||||
testServiceResult match {
|
||||
case Failure(e) =>
|
||||
// Log error including to Sentry - likely missing API key
|
||||
val errorMessage = e match {
|
||||
case _: ExceptionInInitializerError =>
|
||||
val cause = Option(e.getCause).map(_.getMessage).getOrElse("unknown cause")
|
||||
s"Failed to initialize $provider provider (missing API key?): $cause"
|
||||
case _ =>
|
||||
s"Failed to create $provider service: ${e.getMessage}"
|
||||
}
|
||||
SimpleTimedLogger.printLogger.logLine(s"LLM validation error: $errorMessage")
|
||||
Sentry.captureException(e)
|
||||
Future.successful(Left(errorMessage))
|
||||
|
||||
case Success(testService) =>
|
||||
// Create a caller and make a minimal test request
|
||||
val testCaller = new ExternalTextGenerationCaller(serviceImpl = testService)
|
||||
testCaller
|
||||
.streamCompletion(
|
||||
inputText = "Say OK",
|
||||
partialCompletion = None,
|
||||
streamingConsumer = _ => () // Discard all output
|
||||
)
|
||||
.map(_ => Right(()))
|
||||
.recover { case e: Exception => Left(s"Validation failed: ${e.getMessage}") }
|
||||
}
|
||||
}
|
||||
|
||||
override def addSettings(
|
||||
request: AddSettingsRequest
|
||||
): Future[AddSettingsResponse] = {
|
||||
gamesManager.addSettings(
|
||||
request.settings.map(kv => kv.key -> kv.value).toVector
|
||||
)
|
||||
Future.successful(AddSettingsResponse())
|
||||
val settingsMap = request.settings.map(kv => kv.key -> kv.value).toMap
|
||||
val settingsVector = settingsMap.toVector
|
||||
|
||||
// Check if any LLM settings are being changed
|
||||
val llmSettingsChanged = settingsMap.keys.exists(llmSettingKeys.contains)
|
||||
|
||||
if llmSettingsChanged then {
|
||||
// Get the effective values (new value if provided, else current value)
|
||||
val currentSettings = SettingsLoader.getAllSettings.map(s => s.name -> s.currentValue).toMap
|
||||
|
||||
val provider = settingsMap.getOrElse("LlmProvider", currentSettings.getOrElse("LlmProvider", "openai"))
|
||||
val modelName = provider.toLowerCase match {
|
||||
case "openai" =>
|
||||
settingsMap.getOrElse("OpenAiModelName", currentSettings.getOrElse("OpenAiModelName", "gpt-5-mini"))
|
||||
case "claude" =>
|
||||
settingsMap.getOrElse(
|
||||
"ClaudeModelName",
|
||||
currentSettings.getOrElse("ClaudeModelName", "claude-sonnet-4-20250514")
|
||||
)
|
||||
case "gemini" =>
|
||||
settingsMap.getOrElse(
|
||||
"GeminiModelName",
|
||||
currentSettings.getOrElse("GeminiModelName", "gemini-2.5-flash-lite")
|
||||
)
|
||||
case _ => ""
|
||||
}
|
||||
|
||||
// Validate before applying
|
||||
validateLlmSettings(provider, modelName).map {
|
||||
case Right(()) =>
|
||||
// Validation passed, apply settings
|
||||
gamesManager.addSettings(settingsVector)
|
||||
// Recreate LLM callers with new settings
|
||||
gamesManager.recreateLlmCallers()
|
||||
SimpleTimedLogger.printLogger.logLine(s"LLM settings updated: provider=$provider, model=$modelName")
|
||||
AddSettingsResponse(success = true)
|
||||
|
||||
case Left(errorMessage) =>
|
||||
// Validation failed, don't apply settings
|
||||
SimpleTimedLogger.printLogger.logLine(s"LLM settings validation failed: $errorMessage")
|
||||
AddSettingsResponse(success = false, errorMessage = errorMessage)
|
||||
}
|
||||
} else {
|
||||
// No LLM settings, just apply normally
|
||||
gamesManager.addSettings(settingsVector)
|
||||
Future.successful(AddSettingsResponse(success = true))
|
||||
}
|
||||
end if
|
||||
}
|
||||
|
||||
override def convertAiToHuman(
|
||||
|
||||
@@ -14,9 +14,7 @@ import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{Shardok
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.client_text.{ClientTextStoreImpl, PregeneratedClientTextStore, UnrequestedClientText}
|
||||
import net.eagle0.eagle.internal.action_result.ActionResult
|
||||
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
|
||||
import net.eagle0.eagle.internal.hero.Hero
|
||||
import net.eagle0.eagle.internal.llm_response.LLMResponse
|
||||
import net.eagle0.eagle.internal.running_games.{RunningGame, RunningGames}
|
||||
import net.eagle0.eagle.internal.shardok_battle as sb_proto
|
||||
import net.eagle0.eagle.library.*
|
||||
@@ -26,6 +24,8 @@ import net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName
|
||||
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
|
||||
import net.eagle0.eagle.library.util.EagleRequire
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedHeroName
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
|
||||
import net.eagle0.eagle.model.proto_converters.shardok_battle.ShardokBattleConverter
|
||||
@@ -122,7 +122,6 @@ object GamesManager {
|
||||
persister: Persister,
|
||||
gameCreation: NewGameCreation,
|
||||
gamePersisterCreation: GamePersisterCreation,
|
||||
gptModelName: String,
|
||||
hexMaps: Map[String, HexMap]
|
||||
): GamesManager =
|
||||
// Lazy loading: Don't load any games at startup.
|
||||
@@ -141,7 +140,6 @@ object GamesManager {
|
||||
persister = persister,
|
||||
gameCreation = gameCreation,
|
||||
gamePersisterCreation = gamePersisterCreation,
|
||||
gptModelName = gptModelName,
|
||||
pregeneratedClientText = pregeneratedTexts,
|
||||
hexMaps = hexMaps
|
||||
)
|
||||
@@ -168,20 +166,26 @@ object GamesManager {
|
||||
userToFid: Map[String, FactionId],
|
||||
pregeneratedHeroes: Vector[HeroWithName]
|
||||
): GameController = {
|
||||
val clientTextStore = ClientTextStoreImpl
|
||||
val loadedTextStore = ClientTextStoreImpl
|
||||
.loaded(
|
||||
pregenerated = pregeneratedTexts,
|
||||
persister = gamePersister
|
||||
)
|
||||
.get
|
||||
|
||||
val existingNames = (history.last.resultingState.killedHeroes ++
|
||||
// Check all heroes' nameTextIds and recover any missing name requests
|
||||
val allHeroes = (history.last.resultingState.killedHeroes ++
|
||||
history.last.resultingState.heroes).values
|
||||
.map(_.nameTextId)
|
||||
.map { nameTextId =>
|
||||
clientTextStore.getText(nameTextId).get
|
||||
}
|
||||
.toVector
|
||||
|
||||
val recoveryResult = MissingHeroNameRecovery.recoverMissingHeroNames(
|
||||
clientTextStore = loadedTextStore,
|
||||
heroes = allHeroes,
|
||||
gameId = gameId,
|
||||
factionIds = history.last.resultingState.factions.keys.toVector
|
||||
)
|
||||
|
||||
val clientTextStore = recoveryResult.clientTextStore
|
||||
val existingNames = recoveryResult.resolvedNames
|
||||
|
||||
val pregenHeroes =
|
||||
pregeneratedHeroes.filterNot(heroWithName => existingNames.contains(heroWithName.name))
|
||||
@@ -249,7 +253,6 @@ class GamesManager(
|
||||
persister: Persister,
|
||||
gameCreation: NewGameCreation,
|
||||
gamePersisterCreation: GamePersisterCreation,
|
||||
gptModelName: String,
|
||||
pregeneratedClientText: PregeneratedClientTextStore,
|
||||
val hexMaps: Map[String, HexMap]
|
||||
) extends BattleUpdateReceiver
|
||||
@@ -267,13 +270,18 @@ class GamesManager(
|
||||
val shardokClient =
|
||||
new ShardokInterfaceGrpcClient(shardokInternalInterface, this)
|
||||
|
||||
private val unrequestedTextHandler = new UnrequestedTextHandler(
|
||||
llmResolver = new LlmResolver(
|
||||
updateReceiver = new LlmUpdateQueuingProxy(this),
|
||||
gptModelName = gptModelName
|
||||
)
|
||||
private val llmResolver = new LlmResolver(
|
||||
updateReceiver = new LlmUpdateQueuingProxy(this),
|
||||
isGameValid = gameId => gameControllerInfos.contains(gameId)
|
||||
)
|
||||
|
||||
private val unrequestedTextHandler = new UnrequestedTextHandler(
|
||||
llmResolver = llmResolver
|
||||
)
|
||||
|
||||
def recreateLlmCallers(): Unit =
|
||||
llmResolver.recreateLlmCallers()
|
||||
|
||||
def getHexMap(mapName: String): HexMap = hexMaps(mapName)
|
||||
|
||||
def begin(): Unit = this.synchronized {
|
||||
@@ -545,10 +553,11 @@ class GamesManager(
|
||||
|
||||
override def receiveStreamingLlmResponses(
|
||||
streamId: String,
|
||||
llmResponses: Vector[LLMResponse],
|
||||
llmRequest: GeneratedTextRequestT,
|
||||
responseTexts: Vector[String],
|
||||
completed: Boolean
|
||||
): Unit = this.synchronized {
|
||||
val gameId = llmResponses.head.llmRequest.get.eagleGameId
|
||||
val gameId = llmRequest.eagleGameId
|
||||
|
||||
// Game may have been deleted while LLM request was in flight (e.g., warmup games)
|
||||
gameControllerInfos.get(gameId) match {
|
||||
@@ -561,7 +570,7 @@ class GamesManager(
|
||||
case Some(controllerInfo) =>
|
||||
synchronizedHandlePostResults(
|
||||
controllerInfo.controller
|
||||
.postStreamingLlmUpdates(streamId, llmResponses, completed)
|
||||
.postStreamingLlmUpdates(streamId, llmRequest.requestId, responseTexts, completed)
|
||||
).postResults
|
||||
|
||||
if completed then {
|
||||
@@ -573,7 +582,7 @@ class GamesManager(
|
||||
}
|
||||
|
||||
override def receiveStreamingLlmFailure(
|
||||
llmRequest: GeneratedTextRequest
|
||||
llmRequest: GeneratedTextRequestT
|
||||
): Unit = this.synchronized {
|
||||
val gameId = llmRequest.eagleGameId
|
||||
|
||||
@@ -587,12 +596,12 @@ class GamesManager(
|
||||
case Some(controllerInfo) =>
|
||||
synchronizedHandlePostResults(
|
||||
controllerInfo.controller
|
||||
.postStreamingLlmFailure(llmRequest)
|
||||
.postStreamingLlmFailure(llmRequest.requestId)
|
||||
).postResults
|
||||
}
|
||||
}
|
||||
|
||||
override def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId] =
|
||||
override def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId] =
|
||||
// Game may have been deleted while LLM request was in flight (e.g., warmup games)
|
||||
gameControllerInfos.get(llmRequest.eagleGameId) match {
|
||||
case None => Vector.empty
|
||||
|
||||
@@ -9,6 +9,7 @@ import net.eagle0.common.{FunctionalRandom, RandomState, SimpleTimedLogger}
|
||||
import net.eagle0.common.llm_integration.{
|
||||
ClaudeServiceImpl,
|
||||
ExternalTextGenerationCaller,
|
||||
GeminiServiceImpl,
|
||||
OpenAIChatCompletionsServiceImpl
|
||||
}
|
||||
import net.eagle0.eagle.client_text.{
|
||||
@@ -19,7 +20,6 @@ import net.eagle0.eagle.client_text.{
|
||||
TextGenerationResult,
|
||||
TextGenerationSuccess
|
||||
}
|
||||
import net.eagle0.eagle.internal.llm_response.LLMResponse
|
||||
import net.eagle0.eagle.library.actions.llm_prompt_generators.{
|
||||
AllianceOfferMessagePromptGenerator,
|
||||
AllianceResolutionMessagePromptGenerator,
|
||||
@@ -56,6 +56,7 @@ import net.eagle0.eagle.library.actions.llm_prompt_generators.{
|
||||
TruceOfferMessagePromptGenerator,
|
||||
TruceResolutionMessagePromptGenerator
|
||||
}
|
||||
import net.eagle0.eagle.library.settings.{ClaudeModelName, GeminiModelName, LlmProvider, OpenAiModelName}
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.{
|
||||
FixedHeroName,
|
||||
@@ -65,12 +66,14 @@ import net.eagle0.eagle.model.action_result.generated_text_request.{
|
||||
}
|
||||
import net.eagle0.eagle.model.proto_converters.GeneratedTextRequestConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.GameId
|
||||
|
||||
sealed trait LlmResolverResult
|
||||
case class LlmResolverDependencyNotSatisfied(notSatisfiedTextId: String) extends LlmResolverResult
|
||||
case object LlmResolverTooManyRequestsInFlight extends LlmResolverResult
|
||||
case object LlmResolverBypassed extends LlmResolverResult
|
||||
case class LlmResolverRequested(future: Future[?]) extends LlmResolverResult
|
||||
case object LlmResolverGameDeleted extends LlmResolverResult
|
||||
|
||||
object LlmResolver {
|
||||
case class LlmRequestWithGameState(
|
||||
@@ -80,30 +83,55 @@ object LlmResolver {
|
||||
)
|
||||
}
|
||||
|
||||
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, gptModelName: String) {
|
||||
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId => Boolean) {
|
||||
import LlmResolver.LlmRequestWithGameState
|
||||
|
||||
implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
private def newChatGptServiceImpl = new OpenAIChatCompletionsServiceImpl(
|
||||
defaultModelName = gptModelName
|
||||
)
|
||||
private def newClaudeServiceImpl = new ClaudeServiceImpl(
|
||||
defaultModelName = "claude-sonnet-4-20250514"
|
||||
)
|
||||
private val callerCount = 2
|
||||
|
||||
private val chatGptCount = 2
|
||||
private val claudeCount = 0
|
||||
private def createLlmCallers(): Vector[ExternalTextGenerationCaller] =
|
||||
LlmProvider.stringValue.toLowerCase match {
|
||||
case "openai" =>
|
||||
(1 to callerCount).map { _ =>
|
||||
new ExternalTextGenerationCaller(
|
||||
serviceImpl = new OpenAIChatCompletionsServiceImpl(defaultModelName = OpenAiModelName.stringValue)
|
||||
)
|
||||
}.toVector
|
||||
case "claude" =>
|
||||
(1 to callerCount).map { _ =>
|
||||
new ExternalTextGenerationCaller(
|
||||
serviceImpl = new ClaudeServiceImpl(defaultModelName = ClaudeModelName.stringValue)
|
||||
)
|
||||
}.toVector
|
||||
case "gemini" =>
|
||||
(1 to callerCount).map { _ =>
|
||||
new ExternalTextGenerationCaller(
|
||||
serviceImpl = new GeminiServiceImpl(defaultModelName = GeminiModelName.stringValue)
|
||||
)
|
||||
}.toVector
|
||||
case provider =>
|
||||
throw new IllegalArgumentException(s"Unknown LLM provider: $provider")
|
||||
}
|
||||
|
||||
lazy private val llmCallers = ((1 to claudeCount).map { _ =>
|
||||
new ExternalTextGenerationCaller(
|
||||
serviceImpl = newClaudeServiceImpl
|
||||
)
|
||||
} ++ (1 to chatGptCount).map { _ =>
|
||||
new ExternalTextGenerationCaller(
|
||||
serviceImpl = newChatGptServiceImpl
|
||||
)
|
||||
}).toVector
|
||||
@volatile private var _llmCallersInitialized: Boolean = false
|
||||
@volatile private var _llmCallers: Vector[ExternalTextGenerationCaller] = Vector.empty
|
||||
def llmCallers: Vector[ExternalTextGenerationCaller] = {
|
||||
if !_llmCallersInitialized then {
|
||||
synchronized {
|
||||
if !_llmCallersInitialized then {
|
||||
_llmCallers = createLlmCallers()
|
||||
_llmCallersInitialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
_llmCallers
|
||||
}
|
||||
|
||||
def recreateLlmCallers(): Unit = synchronized {
|
||||
_llmCallers = createLlmCallers()
|
||||
_llmCallersInitialized = true
|
||||
}
|
||||
|
||||
lazy private val fileWriter =
|
||||
new FileWriter(s"/tmp/llm_prompts_${System.currentTimeMillis()}.txt", true)
|
||||
@@ -180,75 +208,75 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, gptModelName: String) {
|
||||
maybePrompt: TextGenerationResult,
|
||||
partialCompletion: Option[String]
|
||||
) =>
|
||||
// Convert to proto for boundaries that need it
|
||||
val llmRequestProto = GeneratedTextRequestConverter.toProto(llmRequest)
|
||||
|
||||
(
|
||||
llmRequest,
|
||||
maybePrompt match {
|
||||
case TextGenerationSuccess(prompt) =>
|
||||
if prompt.contains("TextGeneration") || prompt
|
||||
.contains("Option[") ||
|
||||
prompt.contains("RandomState[")
|
||||
then {
|
||||
print(
|
||||
s"Bad prompt for LLM request ${llmRequest.requestId}:\n$prompt\n"
|
||||
)
|
||||
}
|
||||
|
||||
llmCallers
|
||||
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
|
||||
.minByOption(_.getInProgressCount)
|
||||
.map { chosenCaller =>
|
||||
log(llmRequest, prompt)
|
||||
|
||||
LlmResolverRequested {
|
||||
chosenCaller
|
||||
.streamCompletion(
|
||||
inputText = prompt,
|
||||
partialCompletion = partialCompletion,
|
||||
streamingConsumer = streamingResults =>
|
||||
updateReceiver.receiveStreamingLlmResponse(
|
||||
streamId = streamingResults.streamId,
|
||||
llmResponse = LLMResponse(
|
||||
llmRequest = Some(llmRequestProto),
|
||||
responseText = streamingResults.value
|
||||
),
|
||||
completed = streamingResults.completed
|
||||
)
|
||||
)
|
||||
.recover {
|
||||
case ex =>
|
||||
// Log the failure for debugging and send to Sentry
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
|
||||
)
|
||||
ex.printStackTrace()
|
||||
Sentry.captureException(ex)
|
||||
// Handle failure by moving request back to unrequested state for retry
|
||||
updateReceiver.receiveStreamingLlmFailure(
|
||||
llmRequest = llmRequestProto
|
||||
)
|
||||
throw ex // Re-throw to maintain error semantics
|
||||
}
|
||||
}
|
||||
// Check if game still exists before sending the request
|
||||
// This prevents wasted LLM calls for games that were deleted (e.g., warmup games
|
||||
// during blue-green deployments)
|
||||
if !isGameValid(llmRequest.eagleGameId) then {
|
||||
(llmRequest, LlmResolverGameDeleted)
|
||||
} else {
|
||||
(
|
||||
llmRequest,
|
||||
maybePrompt match {
|
||||
case TextGenerationSuccess(prompt) =>
|
||||
if prompt.contains("TextGeneration") || prompt
|
||||
.contains("Option[") ||
|
||||
prompt.contains("RandomState[")
|
||||
then {
|
||||
print(
|
||||
s"Bad prompt for LLM request ${llmRequest.requestId}:\n$prompt\n"
|
||||
)
|
||||
}
|
||||
.getOrElse(LlmResolverTooManyRequestsInFlight)
|
||||
case TextGenerationDependencyInProgress(
|
||||
notSatisfiedTextId,
|
||||
_ /* partialText */
|
||||
) =>
|
||||
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
|
||||
|
||||
case TextGenerationDependencyWaiting(notSatisfiedTextId) =>
|
||||
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
|
||||
llmCallers
|
||||
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
|
||||
.minByOption(_.getInProgressCount)
|
||||
.map { chosenCaller =>
|
||||
log(llmRequest, prompt)
|
||||
|
||||
case TextGenerationDependencyUnknown(notSatisfiedTextId) =>
|
||||
throw new EagleInternalException(
|
||||
s"Unknown dependency for LLM request ${llmRequest.requestId}: $notSatisfiedTextId"
|
||||
)
|
||||
}
|
||||
)
|
||||
LlmResolverRequested {
|
||||
chosenCaller
|
||||
.streamCompletion(
|
||||
inputText = prompt,
|
||||
partialCompletion = partialCompletion,
|
||||
streamingConsumer = streamingResults =>
|
||||
updateReceiver.receiveStreamingLlmResponse(
|
||||
streamId = streamingResults.streamId,
|
||||
llmRequest = llmRequest,
|
||||
responseText = streamingResults.value,
|
||||
completed = streamingResults.completed
|
||||
)
|
||||
)
|
||||
.recover {
|
||||
case ex =>
|
||||
// Log the failure for debugging and send to Sentry
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
|
||||
)
|
||||
ex.printStackTrace()
|
||||
Sentry.captureException(ex)
|
||||
// Handle failure by moving request back to unrequested state for retry
|
||||
updateReceiver.receiveStreamingLlmFailure(llmRequest)
|
||||
throw ex // Re-throw to maintain error semantics
|
||||
}
|
||||
}
|
||||
}
|
||||
.getOrElse(LlmResolverTooManyRequestsInFlight)
|
||||
case TextGenerationDependencyInProgress(
|
||||
notSatisfiedTextId,
|
||||
_ /* partialText */
|
||||
) =>
|
||||
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
|
||||
|
||||
case TextGenerationDependencyWaiting(notSatisfiedTextId) =>
|
||||
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
|
||||
|
||||
case TextGenerationDependencyUnknown(notSatisfiedTextId) =>
|
||||
throw new EagleInternalException(
|
||||
s"Unknown dependency for LLM request ${llmRequest.requestId}: $notSatisfiedTextId"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package net.eagle0.eagle.service
|
||||
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
|
||||
import net.eagle0.eagle.internal.llm_response.LLMResponse
|
||||
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
|
||||
@@ -8,11 +8,12 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
|
||||
private sealed trait QueuedLlmUpdate
|
||||
private case class StreamingLlmResponse(
|
||||
streamId: String,
|
||||
llmResponse: LLMResponse,
|
||||
llmRequest: GeneratedTextRequestT,
|
||||
responseText: String,
|
||||
completed: Boolean
|
||||
) extends QueuedLlmUpdate
|
||||
private case class StreamingLlmFailure(
|
||||
llmRequest: GeneratedTextRequest
|
||||
llmRequest: GeneratedTextRequestT
|
||||
) extends QueuedLlmUpdate
|
||||
|
||||
private val queue = new scala.collection.mutable.Queue[QueuedLlmUpdate]
|
||||
@@ -21,20 +22,21 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
|
||||
|
||||
def receiveStreamingLlmResponse(
|
||||
streamId: String,
|
||||
llmResponse: LLMResponse,
|
||||
llmRequest: GeneratedTextRequestT,
|
||||
responseText: String,
|
||||
completed: Boolean
|
||||
): Unit = queue.synchronized {
|
||||
queue.enqueue(StreamingLlmResponse(streamId, llmResponse, completed))
|
||||
queue.enqueue(StreamingLlmResponse(streamId, llmRequest, responseText, completed))
|
||||
queue.notifyAll()
|
||||
}
|
||||
|
||||
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequest): Unit =
|
||||
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequestT): Unit =
|
||||
queue.synchronized {
|
||||
queue.enqueue(StreamingLlmFailure(llmRequest))
|
||||
queue.notifyAll()
|
||||
}
|
||||
|
||||
def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId] =
|
||||
def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId] =
|
||||
destination.aiPlayers(llmRequest)
|
||||
|
||||
private class Consumer extends Runnable {
|
||||
@@ -63,17 +65,18 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
|
||||
case (streamId, messages) =>
|
||||
destination.receiveStreamingLlmResponses(
|
||||
streamId,
|
||||
messages.map(_.llmResponse),
|
||||
messages.last.completed
|
||||
llmRequest = messages.head.llmRequest,
|
||||
responseTexts = messages.map(_.responseText),
|
||||
completed = messages.last.completed
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
val updateIds = updates.map {
|
||||
case StreamingLlmResponse(streamId, llmResponse, _) =>
|
||||
s"response:${llmResponse.llmRequest.map(_.id).getOrElse("unknown")}"
|
||||
case StreamingLlmFailure(llmRequest) =>
|
||||
s"failure:${llmRequest.id}"
|
||||
case StreamingLlmResponse(_, llmRequest, _, _) =>
|
||||
s"response:${llmRequest.requestId}"
|
||||
case StreamingLlmFailure(llmRequest) =>
|
||||
s"failure:${llmRequest.requestId}"
|
||||
}.mkString(", ")
|
||||
System.err.println(
|
||||
s"ERROR: LlmUpdateQueuingProxy consumer caught exception processing updates [$updateIds]: ${e.getMessage}"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package net.eagle0.eagle.service
|
||||
|
||||
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
|
||||
import net.eagle0.eagle.internal.llm_response.LLMResponse
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
/** Receives LLM streaming updates. Uses Scala domain types instead of protos. */
|
||||
trait LlmUpdateReceiver {
|
||||
def receiveStreamingLlmResponses(
|
||||
streamId: String,
|
||||
llmResponses: Vector[LLMResponse],
|
||||
llmRequest: GeneratedTextRequestT,
|
||||
responseTexts: Vector[String],
|
||||
completed: Boolean
|
||||
): Unit
|
||||
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequest): Unit
|
||||
def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId]
|
||||
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequestT): Unit
|
||||
def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package net.eagle0.eagle.service
|
||||
|
||||
import net.eagle0.common.SimpleTimedLogger
|
||||
import net.eagle0.eagle.{FactionId, GameId}
|
||||
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationSuccess}
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedHeroName
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
|
||||
/**
|
||||
* Utility for recovering missing hero name text requests.
|
||||
*
|
||||
* When a hero exists in the game state but its name text is missing from the ClientTextStore, this utility can recreate
|
||||
* the name generation request so the name gets generated.
|
||||
*/
|
||||
object MissingHeroNameRecovery {
|
||||
|
||||
/** Pattern for randomly generated hero names: "hn_<heroId>" */
|
||||
private val heroNamePattern = """^hn_(\d+)$""".r
|
||||
|
||||
/**
|
||||
* Result of recovering missing hero names.
|
||||
*
|
||||
* @param clientTextStore
|
||||
* The updated text store with any missing name requests added
|
||||
* @param resolvedNames
|
||||
* Names that were successfully resolved (already in the store)
|
||||
* @param recoveredCount
|
||||
* Number of missing names that were recovered (requests added)
|
||||
*/
|
||||
case class RecoveryResult(
|
||||
clientTextStore: ClientTextStore,
|
||||
resolvedNames: Vector[String],
|
||||
recoveredCount: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks heroes for missing name texts and recovers them by adding GeneratedHeroName requests.
|
||||
*
|
||||
* For each hero:
|
||||
* - If the name text exists, it's added to resolvedNames
|
||||
* - If the name text is missing and matches the "hn_X" format, a GeneratedHeroName request is created
|
||||
* - If the name text is missing and doesn't match "hn_X" (e.g., "fhn_" pregenerated names), an exception is thrown
|
||||
*
|
||||
* @param clientTextStore
|
||||
* The text store to check and update
|
||||
* @param heroes
|
||||
* Heroes to check for missing names
|
||||
* @param gameId
|
||||
* Game ID for the GeneratedHeroName request
|
||||
* @param factionIds
|
||||
* Faction IDs for text accessibility
|
||||
* @return
|
||||
* RecoveryResult with updated store, resolved names, and recovery count
|
||||
*/
|
||||
def recoverMissingHeroNames(
|
||||
clientTextStore: ClientTextStore,
|
||||
heroes: Iterable[HeroT],
|
||||
gameId: GameId,
|
||||
factionIds: Vector[FactionId]
|
||||
): RecoveryResult = {
|
||||
val (updatedStore, names, recovered) =
|
||||
heroes.foldLeft((clientTextStore, Vector[String](), 0)) {
|
||||
case ((cts, names, recoveredCount), hero) =>
|
||||
cts.getText(hero.nameTextId) match {
|
||||
case TextGenerationSuccess(name) =>
|
||||
(cts, names :+ name, recoveredCount)
|
||||
|
||||
case other =>
|
||||
// Text is missing or incomplete - try to self-heal for "hn_X" format names
|
||||
hero.nameTextId match {
|
||||
case heroNamePattern(heroIdStr) =>
|
||||
val heroId = heroIdStr.toInt
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"WARNING: Self-healing missing hero name for hero $heroId (textId=${hero.nameTextId}). " +
|
||||
s"Text status was: ${other.getClass.getSimpleName}. " +
|
||||
s"Adding GeneratedHeroName request to unrequested texts."
|
||||
)
|
||||
// Create a new name generation request
|
||||
val nameRequest = GeneratedHeroName(
|
||||
requestId = hero.nameTextId,
|
||||
eagleGameId = gameId,
|
||||
gender = hero.pronounGender
|
||||
)
|
||||
val updatedCts = cts.withAddedTextRequest(
|
||||
id = hero.nameTextId,
|
||||
accessibleTo = factionIds,
|
||||
llmRequest = nameRequest,
|
||||
requestedAfterHistoryCount = 1 // Use 1 since this is recovery
|
||||
)
|
||||
// Don't add to names - it will be generated when UnrequestedTextHandler runs
|
||||
(updatedCts, names, recoveredCount + 1)
|
||||
|
||||
case _ =>
|
||||
// For non-hn_ format names (like fhn_ pregenerated), this is a real error
|
||||
throw new EagleInternalException(
|
||||
s"Text generation dependency unknown for hero ${hero.id}: ${hero.nameTextId}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecoveryResult(updatedStore, names, recovered)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ object ServerSetupHelpers {
|
||||
|
||||
def newGamesManager(
|
||||
shardokInterfaceAddress: String,
|
||||
gptModelName: String,
|
||||
securityConfig: ShardokSecurityConfig = ShardokSecurityConfig()
|
||||
): GamesManager = {
|
||||
implicit val ec = scala.concurrent.ExecutionContext.global
|
||||
@@ -103,7 +102,6 @@ object ServerSetupHelpers {
|
||||
persister = CompoundPersister(persisters),
|
||||
gameCreation = FixedNewGameCreation,
|
||||
gamePersisterCreation = LocalGamePersisterCreation,
|
||||
gptModelName = gptModelName,
|
||||
hexMaps = hexMaps
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,10 @@ class UnrequestedTextHandler(llmResolver: LlmResolver) {
|
||||
)
|
||||
case LlmResolverTooManyRequestsInFlight =>
|
||||
acc
|
||||
case LlmResolverGameDeleted =>
|
||||
// Game was deleted (e.g., warmup game during blue-green deployment)
|
||||
// Just skip the request - it will be cleaned up when the game's data is deleted
|
||||
acc
|
||||
case LlmResolverDependencyNotSatisfied(unsatisfiedTextId) =>
|
||||
acc.copy(stuckEntries =
|
||||
acc.stuckEntries :+ StuckUnrequested(
|
||||
@@ -205,6 +209,9 @@ class UnrequestedTextHandler(llmResolver: LlmResolver) {
|
||||
s"Moving stalled incomplete text ${llmRequest.requestId} back to unrequested due to unsatisfied dependency"
|
||||
)
|
||||
cts.withMovedBackToUnrequested(llmRequest.requestId)
|
||||
case (cts, (_, LlmResolverGameDeleted)) =>
|
||||
// Game was deleted - just skip, texts will be cleaned up with the game
|
||||
cts
|
||||
case (cts, _) =>
|
||||
cts
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ import net.eagle0.eagle.api.eagle.ShardokActionResultResponse.SingleShardokGameR
|
||||
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.client_text.{ClientText, ClientTextStore, ClientTextStoreWithUpdate}
|
||||
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
|
||||
import net.eagle0.eagle.internal.llm_response.LLMResponse
|
||||
import net.eagle0.eagle.library.{EagleInternalException, Engine, EngineAndResults}
|
||||
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
@@ -368,21 +366,37 @@ final case class GameController(
|
||||
}
|
||||
)
|
||||
|
||||
val newEngine = engine.withHistory(newHistory)
|
||||
|
||||
// Push Shardok updates to human clients
|
||||
val updatedHumanClients = GameController.humanClientsAfterPostingResults(
|
||||
humanClients = humanClients,
|
||||
clientTextStore = clientTextStore,
|
||||
visibilityExtensions = Vector.empty,
|
||||
engine = newEngine,
|
||||
fullHistory = newHistory
|
||||
)
|
||||
|
||||
GameControllerWithPostResults(
|
||||
gameController = copy(engine = engine.withHistory(newHistory), fullHistory = newHistory),
|
||||
gameController = copy(
|
||||
engine = newEngine,
|
||||
fullHistory = newHistory,
|
||||
humanClients = updatedHumanClients
|
||||
),
|
||||
postResults = PostResults(gameState = None, battles = Vector(), results = Vector())
|
||||
)
|
||||
}
|
||||
|
||||
private def postOneLlmResponse(
|
||||
streamId: String,
|
||||
llmResponse: LLMResponse,
|
||||
requestId: String,
|
||||
responseText: String,
|
||||
completed: Boolean
|
||||
): GameController =
|
||||
this.clientTextStore
|
||||
.withAppendedText(
|
||||
id = llmResponse.llmRequest.get.id,
|
||||
newText = llmResponse.responseText,
|
||||
id = requestId,
|
||||
newText = responseText,
|
||||
complete = completed
|
||||
) match {
|
||||
case ClientTextStoreWithUpdate(cts, updatedText) =>
|
||||
@@ -404,21 +418,18 @@ final case class GameController(
|
||||
|
||||
def postStreamingLlmUpdates(
|
||||
streamId: String,
|
||||
llmResponses: Vector[LLMResponse],
|
||||
requestId: String,
|
||||
responseTexts: Vector[String],
|
||||
completed: Boolean
|
||||
): GameControllerWithPostResults = {
|
||||
val requestId = llmResponses.head.llmRequest.get.id
|
||||
internalRequire(
|
||||
llmResponses.forall(_.llmRequest.get.id == requestId),
|
||||
"All LLM responses must have the same ID"
|
||||
)
|
||||
val controllerWithHandledStreams = llmResponses match {
|
||||
internalRequire(responseTexts.nonEmpty, "responseTexts is empty")
|
||||
val controllerWithHandledStreams = responseTexts match {
|
||||
case h :+ t =>
|
||||
h.foldLeft(this) {
|
||||
case (innerC, llmResponse) =>
|
||||
innerC.postOneLlmResponse(streamId, llmResponse, completed = false)
|
||||
}.postOneLlmResponse(streamId, t, completed)
|
||||
case _ => throw new EagleInternalException("llmResponses is empty")
|
||||
case (innerC, responseText) =>
|
||||
innerC.postOneLlmResponse(streamId, requestId, responseText, completed = false)
|
||||
}.postOneLlmResponse(streamId, requestId, t, completed)
|
||||
case _ => throw new EagleInternalException("responseTexts is empty")
|
||||
}
|
||||
|
||||
val currentState = controllerWithHandledStreams.engine.currentState
|
||||
@@ -433,10 +444,10 @@ final case class GameController(
|
||||
}
|
||||
|
||||
def postStreamingLlmFailure(
|
||||
llmRequest: GeneratedTextRequest
|
||||
requestId: String
|
||||
): GameControllerWithPostResults = {
|
||||
val updatedClientTextStore =
|
||||
clientTextStore.withMovedBackToUnrequested(llmRequest.id)
|
||||
clientTextStore.withMovedBackToUnrequested(requestId)
|
||||
val currentState = engine.currentState
|
||||
|
||||
GameControllerWithPostResults(
|
||||
|
||||
@@ -90,6 +90,7 @@ scala_library(
|
||||
name = "s3_utils",
|
||||
srcs = ["S3Utils.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__pkg__",
|
||||
@@ -100,6 +101,7 @@ scala_library(
|
||||
],
|
||||
deps = [
|
||||
":persistence_pkg",
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence/credentials",
|
||||
"@maven//:org_reactivestreams_reactive_streams",
|
||||
|
||||
@@ -7,13 +7,19 @@ import scala.annotation.tailrec
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.{Failure, Try}
|
||||
|
||||
import net.eagle0.common.SimpleTimedLogger
|
||||
import net.eagle0.eagle.service.persistence
|
||||
import net.eagle0.eagle.service.persistence.credentials.S3Credentials
|
||||
import net.eagle0.eagle.GameId
|
||||
import software.amazon.awssdk.core.async.AsyncRequestBody
|
||||
import software.amazon.awssdk.regions.Region
|
||||
import software.amazon.awssdk.services.s3.{S3AsyncClient, S3Client}
|
||||
import software.amazon.awssdk.services.s3.model.{GetObjectRequest, ListObjectsV2Request, PutObjectRequest}
|
||||
import software.amazon.awssdk.services.s3.model.{
|
||||
GetObjectRequest,
|
||||
HeadBucketRequest,
|
||||
ListObjectsV2Request,
|
||||
PutObjectRequest
|
||||
}
|
||||
import software.amazon.awssdk.transfer.s3.model.UploadRequest
|
||||
import software.amazon.awssdk.transfer.s3.S3TransferManager
|
||||
|
||||
@@ -41,6 +47,31 @@ object S3Utils {
|
||||
)
|
||||
.build
|
||||
|
||||
/**
|
||||
* Eagerly initialize S3 clients during startup to avoid first-connection latency. The AWS SDK performs lazy
|
||||
* initialization (profile loading, TLS handshake, etc.) which can add 5+ seconds to the first request.
|
||||
*/
|
||||
def warmup(): Unit =
|
||||
if S3Credentials.isEnabled then {
|
||||
SimpleTimedLogger.printLogger.logLine("Warming up S3 client...")
|
||||
try {
|
||||
// Force initialization of the lazy transferManager
|
||||
val _ = transferManager
|
||||
|
||||
// Make a lightweight S3 call to complete TLS handshake and connection setup
|
||||
val s3 = syncClient()
|
||||
s3.headBucket(HeadBucketRequest.builder().bucket(mainBucketName).build())
|
||||
|
||||
SimpleTimedLogger.printLogger.logLine("S3 client warmup complete")
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
// Log but don't fail startup - S3 operations will retry as needed
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"S3 warmup warning (non-fatal): ${e.getMessage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val runningGamesKeyPrefix = "eagle/save/"
|
||||
|
||||
def makeS3Prefix(gameId: GameId): String =
|
||||
|
||||
@@ -509,7 +509,6 @@ scala_test(
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero/concrete",
|
||||
"//src/test/scala/net/eagle0/common:proto_matchers",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -694,7 +693,6 @@ scala_test(
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
|
||||
"//src/test/scala/net/eagle0/common:proto_matchers",
|
||||
"@maven//:org_scalamock_scalamock_3",
|
||||
],
|
||||
)
|
||||
|
||||
+3
-1
@@ -156,7 +156,7 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
|
||||
case _ => fail("Text generation failed")
|
||||
}
|
||||
val expectedText =
|
||||
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for control of the kingdom. Write a 250-word chronicle of the state of the war, as told by an in-universe chronicler, who writes in the style and format of a modern journalist (but without reproducing any copyrighted material), as of October of the Year of the Realm 343.
|
||||
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for control of the kingdom. Write a chronicle of the state of the war, as told by an in-universe chronicler, who writes in the style and format of a modern journalist (but without reproducing any copyrighted material), as of October of the Year of the Realm 343. IMPORTANT: Keep your response to approximately 250 words (about 1.5 pages). Be concise and focus only on the most significant events.
|
||||
|
|
||||
|${MapDescription.mapDescription}
|
||||
|
|
||||
@@ -180,6 +180,8 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
|
||||
|The Eagle's Faction, led by The Eagle, controls Gorania. The Eagle is a mysterious stranger from afar.
|
||||
|The King's Loyalists, led by Bregos Fyar, controls Faluria. Bregos Fyar is the King of the realm.
|
||||
|Vengeance, led by Bridget, controls Hakaria and Igaria. Bridget is an enigmatic mage.
|
||||
|---
|
||||
|REMINDER: Keep the chronicle to approximately 250 words. Be selective about which events to include - focus on the 2-3 most significant developments rather than cataloguing everything.
|
||||
|""".stripMargin
|
||||
|
||||
generatedText shouldBe expectedText
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user