Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 3c92ef3ac0 Convert battle request and conquest actions to use Scala types
Convert three RoundPhaseAdvancer actions to accept Scala GameState instead of
individual proto-converted fields:
- PerformUncontestedConquestAction
- EndAttackDecisionPhaseAction
- RequestBattlesAction

Each action now extracts the fields it needs internally, simplifying the call
sites in RoundPhaseAdvancer.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:53:05 -08:00
203 changed files with 5829 additions and 12229 deletions
-3
View File
@@ -29,9 +29,6 @@ common --javacopt="-Xlint:-options"
common --linkopt=-Wl
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
-3
View File
@@ -6,7 +6,4 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
-64
View File
@@ -1,64 +0,0 @@
name: Build Linux Sysroot
on:
workflow_dispatch:
inputs:
version:
description: 'Sysroot version (e.g., v2, v3)'
required: true
default: 'v2'
type: string
permissions:
contents: read
jobs:
build-sysroot:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot
path: tools/sysroot/output/
- name: Install AWS CLI
run: |
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== Sysroot uploaded ==="
echo "URL: https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
-72
View File
@@ -1,72 +0,0 @@
name: Docker Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-eagle:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle Docker image
run: bazel build //ci:eagle_server_image
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
- name: Push Eagle image to DO registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: bazel run //ci:eagle_server_push
build-shardok:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok Docker image (cross-compile for Linux)
run: bazel build --platforms=//:linux_x86_64 //ci:shardok_server_image
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
- name: Push Shardok image to DO registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: bazel run --platforms=//:linux_x86_64 //ci:shardok_server_push
-9
View File
@@ -3,15 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
package(default_visibility = ["//visibility:public"])
# Platform for cross-compiling to Linux x86_64
platform(
name = "linux_x86_64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
)
gazelle(name = "gazelle")
# gazelle:proto file
-25
View File
@@ -216,31 +216,6 @@ to be used for different players or game situations within the same server proce
- Map validation tests ensure game content integrity
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
### Scala Testing Patterns
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
```scala
// BAD - don't do this
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
changedHero.heroId shouldBe 19
// GOOD - use inside() pattern
import org.scalatest.Inside.inside
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
changedHero.heroId shouldBe 19
changedHero.vigorChange shouldBe StatDelta(17.2)
}
```
The `inside()` pattern:
- Provides better error messages when the type doesn't match
- Is idiomatic ScalaTest
- Works with pattern matching for more complex assertions
## Performance Testing
When making performance-related changes to the AI or engine:
+17 -65
View File
@@ -26,69 +26,50 @@ scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
"scala_config",
)
scala_config.settings(scala_version = SCALA_VERSION)
scala_deps = use_extension(
"@rules_scala//scala/extensions:deps.bzl",
"scala_deps",
)
scala_deps.scala()
scala_deps.scalatest()
scala_deps.scala_proto()
#
# Language Support - C++
#
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.2",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.2",
)
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
llvm.sysroot(
name = "llvm_toolchain_linux",
label = "@linux_sysroot//sysroot",
targets = ["linux-x86_64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
# Built by: .github/workflows/build_sysroot.yml
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
sysroot(
name = "linux_sysroot",
# TODO: Update sha256 after running sysroot build workflow with version v2
sha256 = "PLACEHOLDER_RUN_SYSROOT_WORKFLOW_FIRST",
urls = ["https://eagle0.sfo3.digitaloceanspaces.com/sysroot/v2/ubuntu_noble_amd64_sysroot.tar.xz"],
)
use_repo(llvm, "llvm_toolchain")
#
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(
go_deps,
"com_github_aws_aws_sdk_go_v2",
@@ -103,15 +84,15 @@ use_repo(
# Platform Support - Apple/iOS
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
#
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.2.10")
@@ -122,32 +103,6 @@ bazel_dep(name = "flatbuffers", version = "25.2.10")
bazel_dep(name = "googletest", version = "1.17.0")
#
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 17)
oci.pull(
name = "eclipse_temurin_17",
digest = "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
oci.pull(
name = "ubuntu_24_04",
image = "docker.io/library/ubuntu",
platforms = ["linux/amd64"],
tag = "24.04",
)
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
#
# Java/Scala Dependencies
#
@@ -155,6 +110,7 @@ use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24
bazel_dep(name = "rules_jvm_external", version = "6.3")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
# Netty
@@ -205,10 +161,6 @@ maven.install(
# Other
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support)
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -217,6 +169,7 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -264,6 +217,5 @@ register_toolchains(
# Set dev_dependency so we can turn this off for swift MacOS builds
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
dev_dependency = True,
)
+88 -275
View File
@@ -26,11 +26,7 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/source.json": "92494d5aa43b96665397dd13ee16023097470fa85e276b93674d62a244de47ee",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.9.3/MODULE.bazel": "66baf724dbae7aff4787bf2245cc188d50cb08e07789769730151c0943587c14",
@@ -55,11 +51,8 @@
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/source.json": "ed8cf0ef05c858dce3661689d0a2b110ff398e63994e178e4f1f7555a8067fed",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
@@ -76,8 +69,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -106,8 +98,6 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
"https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b",
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
@@ -147,10 +137,6 @@
"https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a",
"https://bcr.bazel.build/modules/grpc/1.71.0/MODULE.bazel": "7fcab2c05530373f1a442c362b17740dd0c75b6a2a975eec8f5bf4c70a37928a",
"https://bcr.bazel.build/modules/grpc/1.71.0/source.json": "60ef8c4c72c8280ae94c05b4f38bf67785acb25477ab8dbac096a9604449ff90",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/MODULE.bazel": "3a4be20f6fc13be32ad44643b8252ef5af09eee936f1d943cd4fd7867fa92826",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/source.json": "b129ab1828492de2c163785bbeb4065c166de52d932524b4317beb5b7f917994",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
@@ -175,7 +161,6 @@
"https://bcr.bazel.build/modules/opentelemetry-proto/1.5.0/source.json": "046b721ce203e88cdaad44d7dd17a86b7200eab9388b663b234e72e13ff7b143",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -240,14 +225,12 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/source.json": "4db99b3f55c90ab28d14552aa0632533e3e8e5e9aea0f5c24ac0014282c2a7c5",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.5/MODULE.bazel": "be41f87587998fe8890cd82ea4e848ed8eb799e053c224f78f3ff7fe1a1d9b74",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/source.json": "9300e71df0cdde0952f10afff1401fa664e9fc5d9ae6204660ba1b158d90d6a6",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
@@ -306,8 +289,6 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
@@ -340,8 +321,7 @@
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
@@ -359,19 +339,14 @@
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/source.json": "6bd3ef95a288dd2bb1582eca332af850c9a5428a23bb92cb1c57c2dfe6cb7369",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/MODULE.bazel": "05239402b7374293359c2f22806f420b75aa5d6f4b15a2eaa809a2c214d58b31",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/source.json": "229a516d282b17a82be54c6e3ae220a1b750fb55a8495567e5c7a9d09423f3e2",
"https://bcr.bazel.build/modules/upb/0.0.0-20211020-160625a/MODULE.bazel": "6cced416be2dc5b9c05efd5b997049ba795e5e4e6fafbe1624f4587767638928",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
"https://bcr.bazel.build/modules/upb/0.0.0-20230907-e7430e6/MODULE.bazel": "3a7dedadf70346e678dc059dbe44d05cbf3ab17f1ce43a1c7a42edc7cbf93fd9",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/MODULE.bazel": "cea509976a77e34131411684ef05a1d6ad194dd71a8d5816643bc5b0af16dc0f",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/source.json": "7227e1fcad55f3f3cab1a08691ecd753cb29cc6380a47bc650851be9f9ad6d20",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.2.13/MODULE.bazel": "aa6deb1b83c18ffecd940c4119aff9567cd0a671d7bba756741cb2ef043a29d5",
@@ -413,7 +388,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"bzlTransitiveDigest": "8iOqbPY5ve3DvjzaI1mJZ8XTiJypN2PeWvcKOvmZLy8=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -1290,247 +1265,6 @@
"recordedRepoMappingEntries": []
}
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"eclipse_temurin_17_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platform": "linux/amd64",
"target_name": "eclipse_temurin_17_linux_amd64",
"bazel_tags": []
}
},
"eclipse_temurin_17": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "eclipse_temurin_17",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platforms": {
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
},
"bzlmod_repository": "eclipse_temurin_17",
"reproducible": true
}
},
"ubuntu_24_04_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platform": "linux/amd64",
"target_name": "ubuntu_24_04_linux_amd64",
"bazel_tags": []
}
},
"ubuntu_24_04": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "ubuntu_24_04",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platforms": {
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
},
"bzlmod_repository": "ubuntu_24_04",
"reproducible": true
}
},
"oci_crane_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_i386": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_i386",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_s390x",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:crane_toolchain_type",
"toolchain": "@oci_crane_{platform}//:crane_toolchain"
}
},
"oci_regctl_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_amd64"
}
},
"oci_regctl_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_arm64"
}
},
"oci_regctl_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_arm64"
}
},
"oci_regctl_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_s390x"
}
},
"oci_regctl_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_amd64"
}
},
"oci_regctl_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "windows_amd64"
}
},
"oci_regctl_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:regctl_toolchain_type",
"toolchain": "@oci_regctl_{platform}//:regctl_toolchain"
}
}
},
"moduleExtensionMetadata": {
"explicitRootModuleDirectDeps": [
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
"reproducible": false
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_tools",
"bazel_tools"
],
[
"rules_oci~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"rules_oci~",
"bazel_features",
"bazel_features~"
],
[
"rules_oci~",
"bazel_skylib",
"bazel_skylib~"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -1559,7 +1293,7 @@
},
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
"general": {
"bzlTransitiveDigest": "5SDZrXQHW6tI/VEw+La2OPOK4ZWm0LGTxnChXOXBCag=",
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -5170,6 +4904,85 @@
]
]
}
},
"@@toolchains_llvm~//toolchain/extensions:llvm.bzl%llvm": {
"general": {
"bzlTransitiveDigest": "afRF0aFOIUrkYl3o040WQ606ep1qciEXzjnAxT3Kek8=",
"usagesDigest": "sYVuhiCAQehFTnGTv0bNtTBR4WorebpWBNxF0mRusyw=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"llvm_toolchain_llvm": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "llvm",
"attributes": {
"alternative_llvm_sources": [],
"auth_patterns": {},
"distribution": "auto",
"exec_arch": "",
"exec_os": "",
"libclang_rt": {},
"llvm_mirror": "",
"llvm_version": "20.1.2",
"llvm_versions": {},
"netrc": "",
"sha256": {},
"strip_prefix": {},
"urls": {}
}
},
"llvm_toolchain": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "toolchain",
"attributes": {
"absolute_paths": false,
"archive_flags": {},
"compile_flags": {},
"conly_flags": {},
"coverage_compile_flags": {},
"coverage_link_flags": {},
"cxx_builtin_include_directories": {},
"cxx_flags": {},
"cxx_standard": {},
"dbg_compile_flags": {},
"exec_arch": "",
"exec_os": "",
"extra_exec_compatible_with": {},
"extra_target_compatible_with": {},
"link_flags": {},
"link_libs": {},
"llvm_versions": {
"": "20.1.2"
},
"opt_compile_flags": {},
"opt_link_flags": {},
"stdlib": {},
"target_settings": {},
"unfiltered_compile_flags": {},
"toolchain_roots": {},
"sysroot": {}
}
}
},
"recordedRepoMappingEntries": [
[
"toolchains_llvm~",
"bazel_skylib",
"bazel_skylib~"
],
[
"toolchains_llvm~",
"bazel_tools",
"bazel_tools"
],
[
"toolchains_llvm~",
"toolchains_llvm",
"toolchains_llvm~"
]
]
}
}
}
}
-128
View File
@@ -1,128 +0,0 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Eagle Server Docker Image
#
# Build: bazel build //ci:eagle_server_image
# Load: bazel run //ci:eagle_server_load
# Push: bazel run //ci:eagle_server_push
#
# Package the deploy JAR
pkg_tar(
name = "eagle_server_jar_layer",
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
package_dir = "/app",
)
# Package the game resources needed at runtime
pkg_tar(
name = "eagle_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/eagle:beasts",
"//src/main/resources/net/eagle0/eagle:game_parameters",
"//src/main/resources/net/eagle0/eagle:headshots",
"//src/main/resources/net/eagle0/eagle:heroes",
"//src/main/resources/net/eagle0/eagle:province_map",
"//src/main/resources/net/eagle0/eagle:settings",
],
package_dir = "/app/resources",
)
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx4g",
"-XX:+UseG1GC",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
},
exposed_ports = ["40032/tcp"],
tars = [
":eagle_server_jar_layer",
":eagle_resources_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:eagle_server_load
oci_load(
name = "eagle_server_load",
image = ":eagle_server_image",
repo_tags = ["eagle0/eagle-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
repository = "registry.digitalocean.com/eagle0/eagle-server",
)
#
# Shardok Server Docker Image
#
# Build: bazel build //ci:shardok_server_image
# Load: bazel run //ci:shardok_server_load
# Push: bazel run //ci:shardok_server_push
#
# Package the Shardok binary
pkg_tar(
name = "shardok_binary_layer",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
# Package the Shardok resources (battalion types, settings)
pkg_tar(
name = "shardok_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
],
package_dir = "/app/resources",
)
# Package the converted maps
pkg_tar(
name = "shardok_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
package_dir = "/app/resources/maps",
)
oci_image(
name = "shardok_server_image",
base = "@ubuntu_24_04_linux_amd64",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:shardok_server_load
oci_load(
name = "shardok_server_load",
image = ":shardok_server_image",
repo_tags = ["eagle0/shardok-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
-47
View File
@@ -1,47 +0,0 @@
# Docker Compose for local testing of production images
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
services:
eagle:
image: eagle0/eagle-server:latest
container_name: eagle-server
ports:
- "40032:40032"
environment:
# Eagle server configuration
EAGLE_GRPC_PORT: "40032"
SHARDOK_HOST: "shardok"
SHARDOK_PORT: "40042"
# Resource paths (relative to /app in container)
EAGLE_RESOURCES_PATH: "/app/resources"
volumes:
# Mount saves directory for persistence
- ./saves:/app/saves
depends_on:
- shardok
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "40032"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
shardok:
image: eagle0/shardok-server:latest
container_name: shardok-server
ports:
- "40042:40042"
- "40052:40052"
environment:
# Shardok server configuration
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "40042"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
+40 -129
View File
@@ -45,12 +45,11 @@
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
| Phase 5c: RoundPhaseAdvancer Actions | **In Progress** | Converting actions called by RoundPhaseAdvancer to take Scala GameState |
### Phase 5c/5d Progress (Complete)
### Phase 5c Progress
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
Actions called by `RoundPhaseAdvancer` that need to accept Scala `GameState`:
| Action | PR | Status |
|--------|-----|--------|
@@ -58,16 +57,10 @@
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
### EngineImpl Progress
| Change | PR | Status |
|--------|-----|--------|
| `recursiveTransform` deleted | #4677 | ✅ Merged |
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Created |
| `EndDiplomacyResolutionPhaseAction` | - | 🔄 In Progress |
| `EndBattleAftermathPhaseAction` | - | Pending |
| Other RoundPhaseAdvancer actions | - | Pending |
### Current Architecture
@@ -106,7 +99,7 @@ Action.execute()
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ ActionResultTApplier.applyActionResults()
→ GameStateC
(Proto conversion only at boundaries)
@@ -114,17 +107,17 @@ Action.execute()
### Key Files to Convert
**Tier 1 - Core Applier:****Complete**
**Tier 1 - Core Applier:**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala
```
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
Create `ActionResultApplier` that applies `ActionResultT` directly to Scala `GameState`.
**Tier 2 - RoundPhaseAdvancer:****Complete**
**Tier 2 - RoundPhaseAdvancer:**
```
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
```
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
Currently has ~20 calls to `ActionResultProtoConverter.toProto()`. After Tier 1, these become unnecessary.
**Tier 3 - Sequencers:**
```
@@ -147,32 +140,6 @@ Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently
4. Once all actions migrated, deprecate/remove proto-based callbacks
5. Remove `lastStateProto` once no longer used
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
| Action | Status |
|--------|--------|
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformReconResolutionAction` | ✅ Migrated |
| `NewRoundAction` | ✅ Migrated (PR #4698) |
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
**TCommandFactory Extraction** (PR #4684):
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
- `TCommandFactory` - lightweight trait with just `makeTCommand`
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
- Actions accepting command factories now use `TCommandFactory` type for better testability
**Tier 4 - History APIs:**
```
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
@@ -182,70 +149,38 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
### ActionResultProto Consumer Inventory
| File | Usage | Status |
| File | Usage | Target |
|------|-------|--------|
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | Heavy proto usage | Blocked by proto dependencies |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `ActionResultTApplierImpl.scala` | Converts T→Proto, delegates to proto applier | Replace with `ActionResultApplier` |
| `RoundPhaseAdvancer.scala` | ~20 converter calls | Eliminate after applier conversion |
| `RandomStateTSequencer.scala` | Converts T→Proto internally | Thread Scala GameState throughout |
| `RandomStateProtoSequencer.scala` | Returns `Vector[ActionResultProto]` | Evaluate if still needed |
| `VigorXPApplier.scala` | Wraps proto results | Convert to work with T |
| `ResolveBattleAction.scala` | 2 converter calls | Convert after dependencies |
| `PerformForcedTurnBackAction.scala` | 1 converter call | Convert after dependencies |
| `EndFreeForAllDecisionPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `EndBattleRequestPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `EndDefenseDecisionPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `EndPleaseRecruitMePhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `InMemoryHistory.scala` | Stores proto results | Vend Scala types, remove proto entirely |
| `PersistedHistory.scala` | Stores proto results | Vend Scala types, convert internally for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
### Estimated Effort
**Progress: 46 of 52 action files (88%) are fully protoless.**
The following 6 actions still have proto usage:
| Action | Proto Usages | Blocker | Effort |
|--------|--------------|---------|--------|
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `NewRoundAction` | 1 | `ChronicleEventGenerator` returns proto | Medium |
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| `ChronicleEventGenerator` to Scala | ~400 | Medium | 1 action |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~3000** | | |
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 46 / 52 (88%) |
| Proto usages in remaining actions | 33 total |
| Biggest blocker | `ResolveBattleAction` (24 usages) |
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
| Component | Lines | Complexity |
|-----------|-------|------------|
| `ActionResultApplier` | ~900 | High (port of proto applier) |
| `RandomStateTSequencer` refactor | ~150 | Medium |
| `RoundPhaseAdvancer` updates | ~100 | Medium |
| History API updates | ~100 | Low |
| Action/utility updates | ~200 | Low |
| **Total** | **~1450** | |
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] `CommandChoiceHelpers` uses Scala types
- [ ] `ActionResultApplier` created and tested
- [ ] `RandomStateTSequencer` threads Scala GameState throughout
- [ ] `RoundPhaseAdvancer` uses T-types internally
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
@@ -261,35 +196,11 @@ Remove remaining direct proto imports from utility classes.
| File | Status |
|------|--------|
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Partially Complete)
The view filter utilities now have Scala overloads for server-side use:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
## Phase 8: Verify Boundaries
@@ -316,7 +227,7 @@ Confirm protos are used correctly at boundaries — and ONLY there.
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Should views also have Scala models, or is proto acceptable for client-facing projections?
---
-940
View File
@@ -1,940 +0,0 @@
# Eagle0 Productionization Plan
## Executive Summary
This document outlines a plan to move Eagle0's Eagle and Shardok servers from a home Mac to cloud infrastructure while maintaining a QA environment on the Mac. The architecture uses DigitalOcean (already integrated via Spaces) with containerized deployments and GitHub Actions CI/CD.
## Current Architecture
```
Unity Client
│ gRPC/TLS (eagle0.net:443)
nginx (home Mac, via router port forward)
├─► Eagle Server (Scala/JVM, port 40032)
│ │
│ │ internal gRPC (port 40042)
│ ▼
└─► Shardok Server (C++, port 40042/40052)
Storage: DigitalOcean Spaces (sfo3.digitaloceanspaces.com)
DNS: eagle0.net → home IP
```
**Key observations:**
- Already using DigitalOcean Spaces for S3-compatible storage
- Eagle server is a deployable JAR (`eagle_server_deploy.jar`)
- Shardok server is a native C++ binary
- nginx handles TLS termination and gRPC routing
- Self-hosted GitHub Actions runner on Mac
---
## Target Architecture
### Production Environment (DigitalOcean)
```
Unity Client
├─► eagle0.net (Production)
│ │
│ ▼
│ DigitalOcean Load Balancer (TLS termination)
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ DigitalOcean Droplet(s) │
│ │ ┌─────────────┬─────────────────┐ │
│ │ │ Eagle │ Shardok │ │
│ │ │ (Docker) │ (Docker) │ │
│ │ │ :40032 │ :40042 │ │
│ │ └─────────────┴─────────────────┘ │
│ └─────────────────────────────────────┘
└─► qa.eagle0.net (QA - home Mac, unchanged)
nginx → Eagle/Shardok (current setup)
```
### Environment Switching
The Unity client already supports configurable server URLs via the connection screen:
- **Production:** `eagle0.net` (default)
- **QA:** `qa.eagle0.net`
No client code changes needed - users can simply type the desired URL.
---
## Cloud Provider: DigitalOcean
### Why DigitalOcean
1. **Already integrated** - S3 Spaces storage at `sfo3.digitaloceanspaces.com` with credentials configured
2. **Simple pricing** - Predictable monthly costs, no surprise bills
3. **Good performance** - SFO3 datacenter is geographically close
4. **Managed services** - Load balancers, managed databases if needed later
5. **Not AWS/GCP** - Per your requirements
### Alternative Considered: Hetzner
- Cheaper for compute ($3.29/mo for 2 vCPU/4GB vs DO's $24/mo)
- Good EU presence but less US coverage
- No managed load balancer (need to run HAProxy/nginx yourself)
- **Recommendation:** Start with DigitalOcean for simplicity; migrate to Hetzner later if cost becomes a concern
---
## Infrastructure Components
### 1. Compute: DigitalOcean Droplets
#### Resource Characteristics
- **Eagle server:** CPU-light, possibly RAM-heavy (JVM). Should be always available.
- **Shardok server:** Very CPU-heavy (AI algorithms). Only needed during tactical battles. Startup time <1 second.
#### Recommended: On-Demand Shardok (Same Droplet)
For current low player count, optimize for cost while maintaining availability:
| Component | Config | Monthly Cost |
|-----------|--------|--------------|
| Droplet | s-2vcpu-4gb | $24/mo |
| Eagle | Always running | - |
| Shardok | Started on-demand by Eagle, stopped after idle | - |
**How it works:**
1. Eagle server runs 24/7 - game is always available
2. When battle starts, Eagle launches Shardok container/process (<1s startup)
3. After battle ends, idle timer starts (e.g., 5 minutes)
4. On timeout, Eagle stops Shardok to free CPU
5. If new battle starts during idle period, Shardok is already warm
**Shardok lifecycle management** (implement in Eagle):
```scala
// Pseudocode for Eagle's Shardok management
object ShardokManager {
private var process: Option[Process] = None
private var idleTimer: Option[Timer] = None
def ensureRunning(): Unit = {
cancelIdleTimer()
if (process.isEmpty) {
process = Some(startShardokProcess())
waitForHealthCheck()
}
}
def onBattleEnd(): Unit = {
idleTimer = Some(scheduleShutdown(5.minutes))
}
private def shutdown(): Unit = {
process.foreach(_.destroy())
process = None
}
}
```
#### Future Scaling Options
As player count grows and Shardok needs more CPU:
**Option A: Upgrade Droplet (Simplest)**
| Droplet | vCPU | RAM | Cost | Use Case |
|---------|------|-----|------|----------|
| s-2vcpu-4gb | 2 shared | 4 GB | $24/mo | Current (few players) |
| s-4vcpu-8gb | 4 shared | 8 GB | $48/mo | Moderate usage |
| c-4 | 4 dedicated | 8 GB | $84/mo | CPU-intensive battles |
| c-8 | 8 dedicated | 16 GB | $168/mo | Multiple concurrent battles |
**Option B: Separate Shardok Droplet (API-driven)**
For heavy Shardok usage with cost optimization:
- **Eagle droplet:** s-1vcpu-2gb ($12/mo) - always on
- **Shardok droplet:** Created on-demand via DigitalOcean API
- c-4 CPU-optimized: $0.125/hour
- c-8 CPU-optimized: $0.25/hour
- Created when battle starts, destroyed after idle
- 30-60s droplet creation time (acceptable if battles are requested in advance)
**Option C: Fly.io for Shardok (Scale-to-Zero)**
For true pay-per-use with fast cold starts:
- **Eagle:** DigitalOcean or Fly.io (~$10/mo)
- **Shardok:** Fly.io with auto-scaling
- Performance VMs: ~$0.0000022/second when running
- Cold start: 2-5 seconds
- Scales to zero when idle
Requires learning Fly.io but offers best cost efficiency for sporadic usage.
**Option D: Multiple Shardok Instances (High Scale)**
For many concurrent battles:
- **Eagle:** Dedicated droplet with more RAM
- **Shardok pool:** Multiple Shardok containers/droplets
- Eagle routes battles to available Shardok instances
- Could use Kubernetes or Docker Swarm for orchestration
This is overkill for now but documented for future reference.
### 2. Load Balancer
**DigitalOcean Load Balancer:** $12/mo
- TLS termination with Let's Encrypt
- Health checks
- Sticky sessions (if needed)
- Can add more droplets later for HA
**Alternative:** Run nginx on the droplet for $0 extra, but lose automatic failover.
### 3. Storage
**Already configured:** DigitalOcean Spaces
- Bucket: `eagle0`
- Region: `sfo3`
- Used for game saves and assets
- Cost: $5/mo base + $0.02/GB storage + $0.01/GB transfer
### 4. DNS
**Option A: DigitalOcean DNS (Recommended)**
- Free with droplets
- Easy integration
- API for automated updates
**Option B: Keep current DNS provider**
- Update A records manually or via script
**DNS Records:**
```
eagle0.net A <DO Load Balancer IP>
qa.eagle0.net A <Home IP> (unchanged)
*.eagle0.net A <DO Load Balancer IP> (wildcard for future)
```
### 5. Firewall
**DigitalOcean Cloud Firewall:** Free
```
Inbound Rules:
- TCP 443 (HTTPS/gRPC) from anywhere → Load Balancer
- TCP 22 (SSH) from your IP only → Droplets
- TCP 40032 (Eagle) from Load Balancer only
- TCP 40042 (Shardok) from Load Balancer only
Outbound Rules:
- All traffic allowed (for external APIs, Spaces, etc.)
```
---
## Container Strategy
### Dockerfiles
**Eagle Server** (`ci/eagle_run.Dockerfile` - already exists, needs enhancement):
```dockerfile
FROM eclipse-temurin:17-jre-alpine
# Add non-root user
RUN addgroup -S eagle && adduser -S eagle -G eagle
WORKDIR /app
# Copy the deploy JAR
COPY --chown=eagle:eagle deploy/eagle_server_deploy.jar ./
# Copy game resources
COPY --chown=eagle:eagle src/main/resources/net/eagle0/eagle/ ./resources/
USER eagle
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget -q --spider http://localhost:40032/health || exit 1
EXPOSE 40032
ENTRYPOINT ["java", "-Xmx4g", "-jar", "eagle_server_deploy.jar"]
CMD ["--eagle-grpc-port", "40032", "--shardok-interface-remote-address", "shardok:40042", "--gpt-model-name", "gpt-4"]
```
**Shardok Server** (new: `ci/shardok_run.Dockerfile`):
```dockerfile
FROM ubuntu:22.04
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libstdc++6 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Add non-root user
RUN useradd -r -s /bin/false shardok
WORKDIR /app
# Copy the Shardok binary and dependencies
COPY --chown=shardok:shardok bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server ./
COPY --chown=shardok:shardok src/main/resources/net/eagle0/shardok/maps/ ./maps/
USER shardok
# Health check (need to implement gRPC health endpoint)
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD ./shardok-server --health-check || exit 1
EXPOSE 40042 40052
ENTRYPOINT ["./shardok-server"]
```
**Docker Compose** (new: `docker-compose.prod.yml`):
```yaml
version: '3.8'
services:
eagle:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
image: eagle0/eagle-server:${VERSION:-latest}
ports:
- "40032:40032"
environment:
- SHARDOK_ADDRESS=shardok:40042
- GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4}
- JAVA_OPTS=-Xmx4g -XX:+UseG1GC
depends_on:
- shardok
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
shardok:
build:
context: .
dockerfile: ci/shardok_run.Dockerfile
image: eagle0/shardok-server:${VERSION:-latest}
ports:
- "40042:40042"
- "40052:40052"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
```
---
## Build Pipeline
### Build Artifacts
The build process produces:
1. **Eagle JAR:** `bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar`
2. **Shardok binary:** `bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server`
### Cross-Compilation for Linux
Current builds target macOS (self-hosted runner). For production Linux deployment:
**Option A: Build in Docker (Recommended)**
```bash
# Build Eagle (JVM - platform independent)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok in Linux container
docker run --rm -v $(pwd):/workspace -w /workspace \
ubuntu:22.04 \
bash -c "apt-get update && apt-get install -y build-essential && bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server"
```
**Option B: Use GitHub-hosted Linux runner**
- Add `runs-on: ubuntu-latest` workflow
- Builds directly on Linux
- May need to cache Bazel to avoid long build times
**Option C: Cross-compile on Mac**
- Configure Bazel for Linux cross-compilation
- More complex setup but faster iteration
**Recommendation:** Option A (Docker build) for Shardok, since Eagle's JAR is platform-independent.
---
## CI/CD Pipeline
### GitHub Actions Workflows
**New workflow:** `.github/workflows/deploy_production.yml`
```yaml
name: Deploy to Production
on:
push:
branches: [main]
paths:
- 'src/main/scala/**'
- 'src/main/cpp/**'
- 'src/main/protobuf/**'
- 'ci/*.Dockerfile'
- 'docker-compose.prod.yml'
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'production'
type: choice
options:
- production
- staging
env:
REGISTRY: registry.digitalocean.com
EAGLE_IMAGE: eagle0/eagle-server
SHARDOK_IMAGE: eagle0/shardok-server
jobs:
build-eagle:
runs-on: self-hosted
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
lfs: false
- name: Set version
id: version
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build Eagle server JAR
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
- name: Copy artifacts
run: |
mkdir -p deploy
cp bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar deploy/
- name: Build Docker image
run: |
docker build -f ci/eagle_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} .
docker tag ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
- name: Push to registry
run: |
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }}
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
build-shardok:
runs-on: ubuntu-latest
needs: []
steps:
- uses: actions/checkout@v4
with:
lfs: false
- name: Set version
id: version
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v2
- name: Cache Bazel
uses: actions/cache@v3
with:
path: ~/.cache/bazel
key: bazel-linux-${{ hashFiles('MODULE.bazel', 'WORKSPACE') }}
- name: Build Shardok server
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
- name: Build Docker image
run: |
mkdir -p bazel-bin/src/main/cpp/net/eagle0/shardok/
cp bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server bazel-bin/src/main/cpp/net/eagle0/shardok/
docker build -f ci/shardok_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} .
docker tag ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
- name: Push to registry
run: |
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }}
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok]
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to DigitalOcean
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script: |
cd /opt/eagle0
# Pull latest images
docker-compose -f docker-compose.prod.yml pull
# Rolling restart (zero-downtime if load balancer configured)
docker-compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for health checks
sleep 30
docker-compose -f docker-compose.prod.yml ps
# Cleanup old images
docker image prune -f
- name: Verify deployment
run: |
# Health check endpoint
curl -f https://eagle0.net/health || exit 1
- name: Notify on failure
if: failure()
run: |
# Add Slack/Discord notification here
echo "Deployment failed!"
```
### Deployment Steps
1. **On push to main:**
- Build Eagle JAR (self-hosted Mac runner - for consistency)
- Build Shardok binary (GitHub-hosted Ubuntu runner)
- Build Docker images
- Push to DigitalOcean Container Registry
2. **Deploy to droplet:**
- SSH to production server
- Pull new images
- `docker-compose up -d` (rolling update)
- Verify health checks
3. **Rollback:**
```bash
# On server
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml pull eagle0/eagle-server:<previous-version>
docker-compose -f docker-compose.prod.yml up -d
```
---
## Server Setup
### Initial Droplet Setup
```bash
#!/bin/bash
# Run on fresh DigitalOcean droplet (Ubuntu 22.04)
# Update system
apt-get update && apt-get upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
# Install Docker Compose
apt-get install -y docker-compose-plugin
# Create deploy user
useradd -m -s /bin/bash -G docker deploy
mkdir -p /home/deploy/.ssh
# Add your SSH public key to /home/deploy/.ssh/authorized_keys
# Create app directory
mkdir -p /opt/eagle0
chown deploy:deploy /opt/eagle0
# Configure Docker to use DigitalOcean Container Registry
docker login registry.digitalocean.com
# Create systemd service for auto-start
cat > /etc/systemd/system/eagle0.service << 'EOF'
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/eagle0
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
User=deploy
Group=deploy
[Install]
WantedBy=multi-user.target
EOF
systemctl enable eagle0
```
### Load Balancer Configuration
**DigitalOcean Load Balancer settings:**
- **Forwarding Rules:**
- HTTPS 443 → HTTP 40032 (Eagle)
- gRPC is HTTP/2, handled automatically
- **Health Checks:**
- Protocol: HTTP
- Port: 40032
- Path: `/health` (need to implement)
- **SSL:**
- Let's Encrypt certificate for `eagle0.net`
- **Settings:**
- Sticky sessions: Disabled (gRPC streams handle this)
- Proxy protocol: Disabled
### nginx on Droplet (Alternative to LB)
If using nginx instead of managed LB:
```nginx
# /etc/nginx/sites-available/eagle0
upstream eagle_backend {
server 127.0.0.1:40032;
keepalive 100;
}
server {
listen 443 ssl http2;
server_name eagle0.net;
ssl_certificate /etc/letsencrypt/live/eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/eagle0.net/privkey.pem;
# gRPC settings
location /net.eagle0.eagle.api.Eagle {
grpc_pass grpc://eagle_backend;
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Rate limiting
limit_req zone=eagle burst=50 nodelay;
}
# Health check endpoint
location /health {
proxy_pass http://127.0.0.1:40032/health;
}
}
# Rate limit zone
limit_req_zone $binary_remote_addr zone=eagle:10m rate=100r/s;
```
---
## Environment Configuration
### Secrets Management
**GitHub Secrets (for CI/CD):**
- `DO_REGISTRY_TOKEN` - DigitalOcean Container Registry token
- `DO_DROPLET_IP` - Production server IP
- `DO_SSH_KEY` - SSH private key for deployment
- `OPENAI_API_KEY` - For LLM integration (if used in production)
- `DO_SPACES_KEY` - Already exists for S3
**On Server (environment variables):**
```bash
# /opt/eagle0/.env
GPT_MODEL_NAME=gpt-4
OPENAI_API_KEY=sk-...
DO_SPACES_KEY=...
DO_SPACES_SECRET=...
JAVA_OPTS=-Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
```
### Configuration Files
**Production config** (`/opt/eagle0/config/eagle0.conf`):
```
monteCarloIterations = 150000
monteCarloThreads = 4
grpcAddress = 0.0.0.0:40042
```
---
## Monitoring & Observability
### Logging
**Docker logging driver:** json-file with rotation
- Logs stored in `/var/lib/docker/containers/<id>/`
- Max 5 files of 100MB each
**Log aggregation options:**
1. **DigitalOcean Logs** - $0 for basic, integrates with Droplets
2. **Papertrail** - $7/mo for 1GB, good search
3. **Self-hosted Loki** - Free, more complex
### Metrics
**Prometheus + Grafana** (optional, for advanced monitoring):
```yaml
# Add to docker-compose.prod.yml
prometheus:
image: prom/prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
grafana:
image: grafana/grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
```
**Key metrics to track:**
- Connection count
- Request latency (p50, p95, p99)
- Error rate
- CPU/memory usage
- Shardok AI search depth/time
### Alerting
**DigitalOcean Monitoring Alerts:**
- CPU > 80% for 5 minutes
- Memory > 90%
- Disk > 85%
- Droplet unreachable
**Uptime monitoring:**
- Use UptimeRobot (free tier) or Better Uptime
- Check `https://eagle0.net/health` every minute
---
## Cost Estimate
### Recommended Starting Configuration
| Component | Monthly Cost | Notes |
|-----------|-------------|-------|
| Droplet (s-2vcpu-4gb) | $24 | Eagle always-on, Shardok on-demand |
| Spaces | ~$5 | Already paying |
| Container Registry | $5 | For Docker images |
| DNS | $0 | Included |
| Bandwidth | ~$0 | 1TB free, then $0.01/GB |
| **Total** | **~$34/mo** | |
### Optional Add-ons
| Component | Monthly Cost | Notes |
|-----------|-------------|-------|
| Load Balancer | +$12 | Only if need HA/failover |
| Monitoring (Papertrail) | +$7 | Better log search |
### Scaling Costs
| Scenario | Droplet | Monthly Cost |
|----------|---------|--------------|
| Current (few players) | s-2vcpu-4gb | $24 |
| Growing usage | s-4vcpu-8gb | $48 |
| CPU-intensive battles | c-4 (dedicated) | $84 |
| High concurrency | c-8 (dedicated) | $168 |
| Separate Shardok (on-demand) | s-1vcpu-2gb + c-4 hourly | $12 + usage |
---
## Migration Plan
### Phase 1: Infrastructure Setup (Day 1)
1. Create DigitalOcean resources:
- Droplet in SFO3 region
- Container Registry
- (Optional) Load Balancer
2. Configure DNS:
- Point `eagle0.net` to new infrastructure
- Keep `qa.eagle0.net` pointing to home IP
3. Set up server:
- Run initial setup script
- Configure Docker and docker-compose
- Test SSH access
### Phase 2: Build Pipeline (Day 2)
1. Create Dockerfiles:
- Enhance `ci/eagle_run.Dockerfile`
- Create `ci/shardok_run.Dockerfile`
2. Create `docker-compose.prod.yml`
3. Set up GitHub Actions:
- Add deployment workflow
- Configure secrets
- Test build pipeline
### Phase 3: Deployment (Day 3)
1. Deploy to production:
- Push first images
- Start containers
- Verify health checks
2. Configure TLS:
- Set up Let's Encrypt
- Update nginx/LB configuration
3. Update DNS:
- Switch `eagle0.net` to production
- Verify client can connect
### Phase 4: Validation (Day 4)
1. Test gameplay:
- Connect from Unity client
- Play through Eagle gameplay
- Test Shardok combat
2. Monitor:
- Check logs for errors
- Verify resource usage
- Test reconnection behavior
3. Document:
- Update runbooks
- Document rollback procedures
### Phase 5: QA Environment (Day 5)
1. Configure `qa.eagle0.net`:
- Keep pointing to home Mac
- Ensure nginx routes correctly
2. Test environment switching:
- Connect to production
- Switch to QA
- Verify different game states
---
## Rollback Procedure
### Quick Rollback (< 5 minutes)
```bash
# SSH to production server
ssh deploy@<droplet-ip>
# Roll back to previous version
cd /opt/eagle0
docker-compose -f docker-compose.prod.yml down
docker pull registry.digitalocean.com/eagle0/eagle-server:<previous-tag>
docker pull registry.digitalocean.com/eagle0/shardok-server:<previous-tag>
VERSION=<previous-tag> docker-compose -f docker-compose.prod.yml up -d
```
### Full Rollback to Home Mac
1. Update DNS: Point `eagle0.net` back to home IP
2. Ensure home Mac servers are running
3. Wait for DNS propagation (5-30 minutes)
---
## Security Checklist
- [ ] SSH key authentication only (disable password)
- [ ] Firewall configured (only 443, 22 from trusted IPs)
- [ ] TLS 1.3 enforced
- [ ] Secrets in environment variables, not in code
- [ ] Container runs as non-root user
- [ ] Rate limiting on gRPC endpoints
- [ ] Regular security updates (`unattended-upgrades`)
- [ ] Remove Shardok internal interface from public nginx (per CONNECTION_ARCHITECTURE.md)
---
## Resolved Questions
1. **Game state migration:** No migration needed. Saves are currently local only. The codebase has a `Persister` pattern (with existing AWS support) that could be adapted to save to DO Spaces in the future.
2. **LLM API keys:** Yes, OpenAI API keys are needed on the Eagle server. Add `OPENAI_API_KEY` to the `.env` file on the production droplet.
3. **Multiple regions:** Not needed for now. Single SFO3 region is sufficient.
## Remaining Open Questions
1. **Backup strategy:** Should we add automated backups for local persistence, or migrate to DO Spaces persistence first?
2. **Autoscaling:** Is traffic predictable enough to use fixed instance, or need autoscaling later?
---
## Next Steps
### Phase 1: Infrastructure
1. **Approve this plan** - Review and discuss any changes
2. **Create DigitalOcean resources** - Droplet (s-2vcpu-4gb), Container Registry
3. **Set up droplet** - Docker, deploy user, firewall, nginx with TLS
### Phase 2: Containerization
4. **Implement Dockerfiles** - Eagle and Shardok containers
5. **Implement Shardok lifecycle management** - Eagle starts/stops Shardok on-demand
- Add `ShardokProcessManager` to Eagle server
- Start Shardok when battle requested
- Stop after idle timeout (5 min)
- Health check before routing traffic
### Phase 3: CI/CD
6. **Set up GitHub Actions** - Build and push Docker images
7. **Add deployment workflow** - SSH deploy to droplet
### Phase 4: Migration
8. **Deploy to production** - Push images, start services
9. **Update DNS** - Point eagle0.net to droplet
10. **Validate** - Test gameplay, monitor logs
11. **Configure QA** - Ensure qa.eagle0.net still works (home Mac)
+12 -102
View File
@@ -1,10 +1,9 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": 289080209,
"__RESOLVED_ARTIFACTS_HASH": -131178107,
"__INPUT_ARTIFACTS_HASH": 571423113,
"__RESOLVED_ARTIFACTS_HASH": 438039003,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
@@ -156,18 +155,6 @@
},
"version": "1.4.2"
},
"com.squareup.okhttp3:okhttp": {
"shasums": {
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
},
"version": "4.12.0"
},
"com.squareup.okhttp3:okhttp-sse": {
"shasums": {
"jar": "bff4fbcaef7aac2d910d4ff46dafaa4e6d15da127df6bac97216da46943a7d4c"
},
"version": "4.12.0"
},
"com.squareup.okhttp:okhttp": {
"shasums": {
"jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa"
@@ -176,15 +163,9 @@
},
"com.squareup.okio:okio": {
"shasums": {
"jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5"
"jar": "a27f091d34aa452e37227e2cfa85809f29012a8ef2501a9b5a125a978e4fcbc1"
},
"version": "3.6.0"
},
"com.squareup.okio:okio-jvm": {
"shasums": {
"jar": "67543f0736fc422ae927ed0e504b98bc5e269fda0d3500579337cb713da28412"
},
"version": "3.6.0"
"version": "2.10.0"
},
"com.thesamet.scalapb:compilerplugin_3": {
"shasums": {
@@ -463,27 +444,15 @@
},
"org.jetbrains.kotlin:kotlin-stdlib": {
"shasums": {
"jar": "55e989c512b80907799f854309f3bc7782c5b3d13932442d0379d5c472711504"
"jar": "b8ab1da5cdc89cb084d41e1f28f20a42bd431538642a5741c52bbfae3fa3e656"
},
"version": "1.9.10"
"version": "1.4.20"
},
"org.jetbrains.kotlin:kotlin-stdlib-common": {
"shasums": {
"jar": "cde3341ba18a2ba262b0b7cf6c55b20c90e8d434e42c9a13e6a3f770db965a88"
"jar": "a7112c9b3cefee418286c9c9372f7af992bd1e6e030691d52f60cb36dbec8320"
},
"version": "1.9.10"
},
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": {
"shasums": {
"jar": "ac6361bf9ad1ed382c2103d9712c47cdec166232b4903ed596e8876b0681c9b7"
},
"version": "1.9.10"
},
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": {
"shasums": {
"jar": "a4c74d94d64ce1abe53760fe0389dd941f6fc558d0dab35e47c085a11ec80f28"
},
"version": "1.9.10"
"version": "1.4.20"
},
"org.jetbrains:annotations": {
"shasums": {
@@ -810,23 +779,12 @@
"org.checkerframework:checker-qual",
"org.ow2.asm:asm"
],
"com.squareup.okhttp3:okhttp": [
"com.squareup.okio:okio",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
],
"com.squareup.okhttp3:okhttp-sse": [
"com.squareup.okhttp3:okhttp",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
],
"com.squareup.okhttp:okhttp": [
"com.squareup.okio:okio"
],
"com.squareup.okio:okio": [
"com.squareup.okio:okio-jvm"
],
"com.squareup.okio:okio-jvm": [
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-common"
],
"com.thesamet.scalapb:compilerplugin_3": [
"com.google.protobuf:protobuf-java",
@@ -1034,13 +992,6 @@
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains:annotations"
],
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": [
"org.jetbrains.kotlin:kotlin-stdlib"
],
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": [
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7"
],
"org.json4s:json4s-ast_3": [
"org.scala-lang:scala3-library_3"
],
@@ -1500,29 +1451,6 @@
"com.google.truth:truth": [
"com.google.common.truth"
],
"com.squareup.okhttp3:okhttp": [
"okhttp3",
"okhttp3.internal",
"okhttp3.internal.authenticator",
"okhttp3.internal.cache",
"okhttp3.internal.cache2",
"okhttp3.internal.concurrent",
"okhttp3.internal.connection",
"okhttp3.internal.http",
"okhttp3.internal.http1",
"okhttp3.internal.http2",
"okhttp3.internal.io",
"okhttp3.internal.platform",
"okhttp3.internal.platform.android",
"okhttp3.internal.proxy",
"okhttp3.internal.publicsuffix",
"okhttp3.internal.tls",
"okhttp3.internal.ws"
],
"com.squareup.okhttp3:okhttp-sse": [
"okhttp3.internal.sse",
"okhttp3.sse"
],
"com.squareup.okhttp:okhttp": [
"com.squareup.okhttp",
"com.squareup.okhttp.internal",
@@ -1531,7 +1459,7 @@
"com.squareup.okhttp.internal.io",
"com.squareup.okhttp.internal.tls"
],
"com.squareup.okio:okio-jvm": [
"com.squareup.okio:okio": [
"okio",
"okio.internal"
],
@@ -1886,7 +1814,6 @@
"kotlin.annotation",
"kotlin.collections",
"kotlin.collections.builders",
"kotlin.collections.jdk8",
"kotlin.collections.unsigned",
"kotlin.comparisons",
"kotlin.concurrent",
@@ -1895,36 +1822,24 @@
"kotlin.coroutines.cancellation",
"kotlin.coroutines.intrinsics",
"kotlin.coroutines.jvm.internal",
"kotlin.enums",
"kotlin.experimental",
"kotlin.internal",
"kotlin.internal.jdk7",
"kotlin.internal.jdk8",
"kotlin.io",
"kotlin.io.encoding",
"kotlin.io.path",
"kotlin.jdk7",
"kotlin.js",
"kotlin.jvm",
"kotlin.jvm.functions",
"kotlin.jvm.internal",
"kotlin.jvm.internal.markers",
"kotlin.jvm.internal.unsafe",
"kotlin.jvm.jdk8",
"kotlin.jvm.optionals",
"kotlin.math",
"kotlin.properties",
"kotlin.random",
"kotlin.random.jdk8",
"kotlin.ranges",
"kotlin.reflect",
"kotlin.sequences",
"kotlin.streams.jdk8",
"kotlin.system",
"kotlin.text",
"kotlin.text.jdk8",
"kotlin.time",
"kotlin.time.jdk8"
"kotlin.time"
],
"org.jetbrains:annotations": [
"org.intellij.lang.annotations",
@@ -2355,11 +2270,8 @@
"com.google.protobuf:protobuf-java",
"com.google.re2j:re2j",
"com.google.truth:truth",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:okhttp-sse",
"com.squareup.okhttp:okhttp",
"com.squareup.okio:okio",
"com.squareup.okio:okio-jvm",
"com.thesamet.scalapb:compilerplugin_3",
"com.thesamet.scalapb:lenses_3",
"com.thesamet.scalapb:protoc-bridge_2.13",
@@ -2412,8 +2324,6 @@
"org.hamcrest:hamcrest-core",
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8",
"org.jetbrains:annotations",
"org.json4s:json4s-ast_3",
"org.json4s:json4s-core_3",
-473
View File
@@ -1,473 +0,0 @@
#!/bin/bash
#
# generate_changelog.sh
#
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
# and sends an HTML email via Fastmail JMAP API.
#
# Usage: ./scripts/generate_changelog.sh [--dry-run]
#
# Configuration files (in ~/.config/eagle0/):
# fastmail_token - API token (required)
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
#
# To set up:
# mkdir -p ~/.config/eagle0
# echo 'your-token' > ~/.config/eagle0/fastmail_token
# chmod 600 ~/.config/eagle0/fastmail_token
#
# # Optional: configure recipients (one per line, # for comments)
# cat > ~/.config/eagle0/changelog_recipient << EOF
# alice@example.com
# bob@example.com
# EOF
#
# The script tracks its last run using a git tag 'changelog-last-run'.
# On first run (no tag), it defaults to the previous Friday at 4pm.
set -euo pipefail
# Ensure homebrew binaries are in PATH
export PATH="/opt/homebrew/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TAG_NAME="changelog-last-run"
DRY_RUN=false
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
CONFIG_DIR="$HOME/.config/eagle0"
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
# Load API token from file or environment
load_api_token() {
# Environment variable takes precedence
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
return 0
fi
# Try loading from config file
if [[ -f "$TOKEN_FILE" ]]; then
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
echo "Loaded API token from $TOKEN_FILE"
export FASTMAIL_API_TOKEN
return 0
fi
fi
return 1
}
# Load recipient emails from config file (one per line)
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
load_recipients_json() {
local recipients=""
if [[ -f "$RECIPIENT_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
line=$(echo "$line" | tr -d '[:space:]')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -n "$recipients" ]]; then
recipients="$recipients, "
fi
recipients="$recipients{\"email\": \"$line\"}"
done < "$RECIPIENT_FILE"
fi
echo "$recipients"
}
# Get human-readable list of recipients
load_recipients_display() {
if [[ -f "$RECIPIENT_FILE" ]]; then
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--dry-run]"
exit 1
;;
esac
done
cd "$REPO_ROOT"
# Get the cutoff date - either from tag or previous Friday 4pm
get_cutoff_date() {
# Try to get the date from the tag
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
# Get the commit date of the tagged commit
git log -1 --format="%aI" "$TAG_NAME"
else
# Calculate previous Friday at 4pm
# Get current day of week (1=Monday, 7=Sunday)
local dow=$(date +%u)
local days_since_friday
if [[ $dow -ge 5 ]]; then
# Friday (5), Saturday (6), or Sunday (7)
days_since_friday=$((dow - 5))
else
# Monday (1) through Thursday (4)
days_since_friday=$((dow + 2))
fi
# Get previous Friday at 4pm in ISO format
if [[ "$(uname)" == "Darwin" ]]; then
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
else
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
fi
fi
}
# Fetch merged PRs since the cutoff date
fetch_merged_prs() {
local since_date="$1"
local output_file="$2"
echo "Fetching PRs merged since: $since_date"
# Use gh to search for merged PRs
gh pr list \
--state merged \
--base main \
--json number,title,body,mergedAt,author \
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
> "$output_file.json"
# Format the output nicely
echo "# Merged PRs since $since_date" > "$output_file"
echo "" >> "$output_file"
# Process each PR
jq -r '
"## PR #\(.number): \(.title)\n" +
"Author: \(.author.login)\n" +
"Merged: \(.mergedAt)\n\n" +
"### Description\n" +
(.body // "(No description)") +
"\n\n---\n"
' "$output_file.json" >> "$output_file"
# Count PRs
local pr_count=$(jq -s 'length' "$output_file.json")
echo "Found $pr_count merged PRs"
rm -f "$output_file.json"
if [[ $pr_count -eq 0 ]]; then
echo "No PRs found since $since_date"
return 1
fi
return 0
}
# Generate synopsis using Claude
generate_synopsis() {
local input_file="$1"
local output_file="$2"
echo "Generating synopsis with Claude..."
# Create a prompt file to avoid shell escaping issues
local prompt_file="/tmp/eagle0_prompt_$$.txt"
# Get repo URL for PR links
local repo_url=$(gh repo view --json url -q '.url')
cat > "$prompt_file" <<PROMPT_HEADER
You are summarizing changes for a weekly engineering update email.
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
Structure:
1. <h1> title (e.g., "Eagle0 Weekly Update")
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
3. Synopsis sections (<h2> headings with bullet point summaries)
4. <hr> divider
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
Guidelines for the SYNOPSIS sections:
- Group related changes together under clear headings (use <h2> tags)
- Use bullet points (<ul><li>) for individual changes
- Highlight any significant new features, breaking changes, or important fixes
- Keep the tone professional but accessible
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
Here are the merged PRs:
PROMPT_HEADER
cat "$input_file" >> "$prompt_file"
echo "" >> "$prompt_file"
echo "Generate the synopsis now:" >> "$prompt_file"
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
local raw_output="/tmp/eagle0_raw_$$.html"
cat "$prompt_file" | claude --print > "$raw_output"
# Wrap in HTML document with UTF-8 charset
cat > "$output_file" <<'HTML_HEAD'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
HTML_HEAD
cat "$raw_output" >> "$output_file"
echo "</body></html>" >> "$output_file"
rm -f "$prompt_file" "$raw_output"
echo "Synopsis generated at: $output_file"
}
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
get_fastmail_session() {
echo "Fetching Fastmail session info..." >&2
# Get session
local session=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
"https://api.fastmail.com/jmap/session")
# Extract account ID (first account)
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
return 1
fi
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
# Get identity ID
local identity_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
\"methodCalls\": [
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
echo "Error: Could not get Fastmail identity ID." >&2
return 1
fi
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
# Get drafts mailbox ID
local mailbox_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
\"methodCalls\": [
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
return 1
fi
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
return 0
}
# Send email via Fastmail JMAP API
send_email_fastmail() {
local synopsis_file="$1"
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
local html_body=$(cat "$synopsis_file" | jq -Rs .)
echo "Sending email via Fastmail JMAP API..."
# Create the email and send it in one request
local response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [
\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\",
\"urn:ietf:params:jmap:submission\"
],
\"methodCalls\": [
[\"Email/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"create\": {
\"draft\": {
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
\"to\": [$recipients_json],
\"subject\": \"$subject\",
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
\"keywords\": {\"\$draft\": true},
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
\"bodyValues\": {
\"body\": {
\"charset\": \"utf-8\",
\"value\": $html_body
}
}
}
}
}, \"0\"],
[\"EmailSubmission/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"onSuccessDestroyEmail\": [\"#sendIt\"],
\"create\": {
\"sendIt\": {
\"emailId\": \"#draft\",
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
}
}
}, \"1\"]
]
}" \
"$FASTMAIL_API")
# Check for errors
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
if [[ -n "$error" ]]; then
echo "Error creating email: $error" >&2
echo "Full response: $response" >&2
return 1
fi
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
if [[ -n "$send_error" ]]; then
echo "Error sending email: $send_error" >&2
echo "Full response: $response" >&2
return 1
fi
echo "Email sent successfully"
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
# Delete existing tag if present
git tag -d "$TAG_NAME" 2>/dev/null || true
git push origin --delete "$TAG_NAME" 2>/dev/null || true
# Create new tag at HEAD
git tag "$TAG_NAME"
git push origin "$TAG_NAME"
echo "Tag updated to current HEAD"
}
# Main
main() {
echo "=== Eagle0 Weekly Changelog Generator ==="
echo ""
# Load API token (only required for actual send)
if [[ "$DRY_RUN" != "true" ]]; then
if ! load_api_token; then
echo "Error: No Fastmail API token found."
echo ""
echo "To create a token:"
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
echo "2. Create a new token with 'Email submission' scope"
echo "3. Save it using one of these methods:"
echo ""
echo " Option A (recommended): Store in config file"
echo " mkdir -p ~/.config/eagle0"
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
echo " chmod 600 ~/.config/eagle0/fastmail_token"
echo ""
echo " Option B: Set environment variable"
echo " export FASTMAIL_API_TOKEN='your-token'"
exit 1
fi
fi
# Get cutoff date
local cutoff_date=$(get_cutoff_date)
echo "Cutoff date: $cutoff_date"
# Create temp files
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
# Fetch PRs
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
echo "No changes to report. Exiting."
exit 0
fi
echo ""
echo "PR details saved to: $pr_file"
# Generate synopsis
generate_synopsis "$pr_file" "$synopsis_file"
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
echo "=== DRY RUN - Synopsis content ==="
cat "$synopsis_file"
echo ""
echo "=== DRY RUN - Skipping email send and tag update ==="
else
# Get Fastmail session info
if ! get_fastmail_session; then
echo "Failed to get Fastmail session info. Exiting."
exit 1
fi
# Determine recipients (from config file, or default to sender)
local recipients_json=$(load_recipients_json)
if [[ -z "$recipients_json" ]]; then
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
else
local recipients_display=$(load_recipients_display)
echo "Sending to: $recipients_display"
fi
# Send email
send_email_fastmail "$synopsis_file" "$recipients_json"
# Update tag for next run
update_tag
fi
echo ""
echo "Done!"
echo "PR details: $pr_file"
echo "Synopsis: $synopsis_file"
}
main
@@ -65,6 +65,11 @@ private:
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
public:
explicit ShardokAIClient(
PlayerId playerId,
@@ -81,12 +86,6 @@ public:
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// Overload that works on copies of state - allows caller to release lock during AI thinking
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
@@ -81,7 +81,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
pi.is_defender(),
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::ITERATIVE_DEEPENING,
AIAlgorithmType::MCTS,
ScoringCalculatorType::MCTS_OPTIMIZED,
mctsConfig);
@@ -111,67 +111,17 @@ void ShardokGameController::DoAIThread() {
if (aiClients.empty()) { printf("No AI players, exiting AI thread.\n"); }
while (aiThreadKeepGoing) {
// Phase 1: Gather data for AI decision (brief lock)
shared_ptr<ShardokAIClient> aiClient;
PlayerId playerId;
GameSettingsSPtr settings;
net::eagle0::shardok::api::GameStateView gsv;
CommandListSPtr availableCommands;
size_t expectedHistoryCount;
{
unique_lock lk(masterLock);
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
playerId = engine->GetCurrentPlayerId();
aiClient = LockedAIClientForPid(playerId);
if (!aiClient) {
// Not an AI player's turn - wait for signal
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
continue;
}
// Get copies of everything the AI needs
settings = engine->GetGameSettings();
gsv = engine->GetGameStateView(playerId);
availableCommands = engine->GetAvailableCommandsForAIPlayer(playerId);
expectedHistoryCount = engine->GetUnfilteredHistoryCount();
}
// Lock released - polls can now get through
if (availableCommands->empty()) {
printf("no commands for player %d\n", playerId);
continue;
while (incomingRegistrations > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Phase 2: AI thinks (NO LOCK - this is the slow part)
const auto results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
// Phase 3: Post the command (brief lock)
{
unique_lock lk(masterLock);
// Verify state hasn't changed while we were thinking
if (engine->GetUnfilteredHistoryCount() != expectedHistoryCount) {
// State changed (e.g., human posted command) - re-evaluate
printf("AI: State changed while thinking, re-evaluating\n");
continue;
}
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
engine->PostCommand(playerId, results.chosenIndex);
unique_lock lk(masterLock);
if (LockedCheckOneAICommand()) {
LockedNotifyClients();
aiThreadKeepGoing = !engine->GameIsOver();
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
} else {
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
}
}
printf("Exiting AI thread.\n");
@@ -186,6 +136,24 @@ ShardokGameController::~ShardokGameController() {
aiThread.join();
}
auto ShardokGameController::LockedCheckOneAICommand() -> bool {
if (engine->GameIsOver()) {
printf("Game is over!\n");
return false;
}
const PlayerId currentPid = engine->GetCurrentPlayerId();
if (const shared_ptr<ShardokAIClient> currentPlayerClient = LockedAIClientForPid(currentPid)) {
const int index = currentPlayerClient->ChooseCommandIndex(*engine).chosenIndex;
engine->PostCommand(currentPid, index);
LockedNotifyClients();
return true;
}
return false;
}
void CheckFactionId(
const unique_ptr<ShardokEngine> &engine,
const PlayerId shardokPlayerId,
@@ -261,7 +229,6 @@ void ShardokGameController::PostPlacementCommands(
}
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
scoped_lock<mutex> guard(masterLock);
return engine->GetCurrentGameStateBytes();
}
@@ -325,9 +292,6 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
-1,
engine->FilterNewResults(-1, startingActionId),
nullptr);
auto gameStateBytes = engine->GetCurrentGameStateBytes();
updates.currentGameState.swap(gameStateBytes);
}
return updates;
@@ -357,74 +321,4 @@ auto ShardokGameController::ResolvedPlayerInfos()
return engine->GetPlayerInfos();
}
void ShardokGameController::RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.push_back(subscriber);
}
void ShardokGameController::UnregisterSubscriber(const StreamSubscriber *subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.erase(
std::remove_if(
subscribers.begin(),
subscribers.end(),
[subscriber](const std::weak_ptr<StreamSubscriber> &weakSub) {
auto sub = weakSub.lock();
return !sub || sub.get() == subscriber;
}),
subscribers.end());
}
auto ShardokGameController::WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool {
int64_t lastPushedActionId = startingActionId;
while (subscriber->IsActive()) {
bool gameOver = false;
GameOverInfo gameOverInfo{};
{
unique_lock<mutex> guard(masterLock);
// Wait for updates or game over
updateCondition.wait(guard, [this, lastPushedActionId] {
return engine->GetUnfilteredHistoryCount() >
static_cast<size_t>(lastPushedActionId) ||
engine->GameIsOver();
});
if (!subscriber->IsActive()) { return false; }
gameOver = engine->GameIsOver();
if (gameOver) {
gameOverInfo.gameStatus = fb::ToProto(engine->GetGameStatus());
gameOverInfo.playerInfos = engine->GetPlayerInfos();
gameOverInfo.endGameUnits = engine->EndGameUnits();
}
}
// Lock released - GetUpdates will acquire its own lock
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
// Get updates outside the lock (GetUpdates acquires masterLock internally)
AllUpdates updates = GetUpdates(lastPushedActionId);
lastPushedActionId = updates.newUnfilteredCount;
if (!updates.mainResults.empty()) {
subscriber->OnUpdate(
updates.mainResults,
updates.filteredResults,
updates.newUnfilteredCount,
updates.currentGameState);
}
}
return false; // Subscriber disconnected
}
} // namespace shardok
@@ -10,7 +10,6 @@
#define ShardokGameController_hpp
#include <functional>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>
@@ -30,50 +29,6 @@ using std::shared_ptr;
using std::unique_ptr;
using std::weak_ptr;
// Forward declaration
class ShardokGameController;
/// Info about a game that has ended, for notifying subscribers
struct GameOverInfo {
vector<net::eagle0::shardok::common::PlayerInfo> playerInfos;
vector<net::eagle0::shardok::storage::ResolvedUnit> endGameUnits;
net::eagle0::shardok::common::GameStatus gameStatus;
};
/// Updates for a single player (includes faction ID for client routing)
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView>& arvs,
const shared_ptr<AvailableCommands>& acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
/// Interface for subscribers that receive streaming updates from a game
class StreamSubscriber {
public:
virtual ~StreamSubscriber() = default;
/// Called when new game updates are available
virtual void OnUpdate(
const vector<ActionResult>& mainResults,
const vector<OnePlayerUpdates>& filteredResults,
int32_t newUnfilteredCount,
const byte_vector& currentGameState) = 0;
/// Called when the game ends
virtual void OnGameOver(const GameOverInfo& info) = 0;
/// Returns true if this subscriber is still active and should receive updates
[[nodiscard]] virtual auto IsActive() const -> bool = 0;
};
class ShardokGameController {
private:
// This lock should be held any time we call into engine or modify clients.
@@ -84,17 +39,10 @@ private:
// Fires whenever there is a new game state update.
mutable std::condition_variable updateCondition{};
// Stream subscribers - protected by separate lock to avoid deadlock with masterLock
mutable std::mutex subscriberLock{};
std::vector<std::weak_ptr<StreamSubscriber>> subscribers{};
string serializedRequest;
unique_ptr<ShardokEngine> engine;
// Cached immutable data - safe to access without lock since it never changes after construction
const GameId cachedGameId;
std::atomic_int incomingRegistrations = 0;
const string mapName;
@@ -112,9 +60,11 @@ private:
void LockedNotifyClients() const;
auto LockedCheckOneAICommand() -> bool;
auto LockedAIClientForPid(PlayerId pid) const -> shared_ptr<ShardokAIClient>;
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine>& e);
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine> &e);
void DoAIThread();
@@ -125,7 +75,6 @@ public:
string serializedRequest = "")
: serializedRequest(std::move(serializedRequest)),
engine(std::move(e)),
cachedGameId(engine->GetGameId()),
mapName(std::move(mapName)),
logFilePath(MakeLogFilePath()),
aiClients(MakeAIClients(engine)),
@@ -149,13 +98,26 @@ public:
PlayerId shardokPlayerId,
int eagleFactionId,
int64_t token,
const vector<UnitPlacementInfo>& infos);
const vector<UnitPlacementInfo> &infos);
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView> &arvs,
const shared_ptr<AvailableCommands> &acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
struct AllUpdates {
vector<ActionResult> mainResults;
vector<OnePlayerUpdates> filteredResults;
int32_t newUnfilteredCount;
byte_vector currentGameState;
};
auto GetUpdates(int64_t startingActionId) -> AllUpdates;
auto GetCurrentGameStateBytes() -> byte_vector;
@@ -165,23 +127,13 @@ public:
auto ResolvedPlayerInfos() -> vector<net::eagle0::shardok::common::PlayerInfo>;
auto EndGameUnits() -> vector<net::eagle0::shardok::storage::ResolvedUnit>;
[[nodiscard]] auto GetGameId() const -> GameId { return cachedGameId; }
[[nodiscard]] auto GetGameId() const -> GameId { return engine->GetGameId(); }
[[nodiscard]] auto GetHexMap() const -> const HexMap * {
return engine->GetCurrentGameState()->hex_map();
}
[[nodiscard]] auto GetLogFilePath() const -> string { return logFilePath; }
/// Register a subscriber to receive streaming updates for this game.
/// The subscriber will receive updates until it becomes inactive or is unregistered.
void RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber);
/// Unregister a subscriber. Safe to call even if the subscriber was never registered.
void UnregisterSubscriber(const StreamSubscriber* subscriber);
/// Wait for game updates, pushing them to the given subscriber.
/// Blocks until the game ends or the subscriber becomes inactive.
/// Returns true if the game ended normally, false if subscriber disconnected.
auto WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool;
};
} // namespace shardok
@@ -529,130 +529,6 @@ auto FromInternalStatus(
throw ShardokInternalErrorException("Bad unit status on resolved unit");
}
auto EagleInterfaceImpl::SubscribeToGame(
ServerContext *context,
const GameSubscriptionRequest *request,
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
if (!controller) { return Status(StatusCode::NOT_FOUND, "Game not found"); }
// Send initial state
GameStatusResponse initialResponse;
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
&initialResponse);
if (!writer->Write(initialResponse)) {
return Status::OK; // Client disconnected
}
// If game was already over, we're done
if (initialResponse.has_game_over_response()) { return Status::OK; }
// Track the actual count after initial response to avoid duplicate sends
const int64_t countAfterInitialResponse =
initialResponse.has_game_update_response()
? initialResponse.game_update_response().total_action_result_count()
: request->game_setup_info().known_result_count();
// Create a subscriber that writes to the gRPC stream
class GrpcStreamSubscriber : public StreamSubscriber {
private:
grpc::ServerWriter<GameStatusResponse> *writer_;
ServerContext *context_;
std::atomic<bool> active_{true};
std::string gameId_;
public:
GrpcStreamSubscriber(
grpc::ServerWriter<GameStatusResponse> *w,
ServerContext *ctx,
std::string gameId)
: writer_(w),
context_(ctx),
gameId_(std::move(gameId)) {}
void OnUpdate(
const vector<ActionResult> &mainResults,
const vector<OnePlayerUpdates> &filteredResults,
int32_t newUnfilteredCount,
const byte_vector &currentGameState) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
response.mutable_game_update_response()->mutable_update_responses()->Add(
begin(mainResults),
end(mainResults));
// Add filtered results for each player with their faction IDs
for (const auto &playerUpdate : filteredResults) {
auto *filtered =
response.mutable_game_update_response()->add_filtered_update_responses();
filtered->set_eagle_faction_id(playerUpdate.eagleFactionId);
filtered->mutable_action_result_views()->Add(
begin(playerUpdate.resultViews),
end(playerUpdate.resultViews));
if (playerUpdate.availableCommands) {
*filtered->mutable_available_commands() = *playerUpdate.availableCommands;
}
}
response.mutable_game_update_response()->set_total_action_result_count(
newUnfilteredCount);
*response.mutable_game_update_response()->mutable_current_game_state() =
std::string(currentGameState.begin(), currentGameState.end());
if (!writer_->Write(response)) { active_ = false; }
}
void OnGameOver(const GameOverInfo &info) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
PopulateGameOverResponse(
gameId_,
info.gameStatus,
info.playerInfos,
info.endGameUnits,
response.mutable_game_over_response());
writer_->Write(response);
active_ = false;
}
[[nodiscard]] auto IsActive() const -> bool override {
return active_ && !context_->IsCancelled();
}
};
auto subscriber =
std::make_shared<GrpcStreamSubscriber>(writer, context, controller->GetGameId());
controller->RegisterSubscriber(subscriber);
// Wait for updates and push them until game ends or subscriber disconnects
// Use countAfterInitialResponse to avoid re-sending results already in initial response
bool gameEnded = controller->WaitForUpdatesAndPush(subscriber, countAfterInitialResponse);
controller->UnregisterSubscriber(subscriber.get());
if (gameEnded) {
printf("SubscribeToGame: Game ended normally\n");
} else {
printf("SubscribeToGame: Subscriber disconnected\n");
}
return Status::OK;
}
} // namespace shardok
#ifndef NDEBUG
@@ -33,7 +33,6 @@ using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusRequest;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::HexMapNamesRequest;
using net::eagle0::common::HexMapNamesResponse;
using net::eagle0::common::HexMapRequest;
@@ -79,11 +78,6 @@ public:
ServerContext* context,
const HexMapNamesRequest* request,
HexMapNamesResponse* response) -> Status override;
auto SubscribeToGame(
ServerContext* context,
const GameSubscriptionRequest* request,
grpc::ServerWriter<GameStatusResponse>* writer) -> Status override;
};
} // namespace shardok
@@ -149,7 +149,6 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManagerEditor.cs" />
<Compile Include="Assets/common/GUIUtils/AutoScrollingText.cs" />
<Compile Include="Assets/Bluetooth/NativeDiceInterfaceImports.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceConqueredNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
@@ -24,7 +24,7 @@ namespace eagle {
public void OnTextUpdate(string text, bool completed) { SetUp(); }
public string TextId() { return _entries.Count > 0 ? CurrentEntry.GeneratedTextId : null; }
public string TextId() { return CurrentEntry.GeneratedTextId; }
private void OnEnable() {
ClientTextProvider.Provider.AddListener(this);
@@ -41,13 +41,11 @@ namespace eagle {
public IList<ChronicleEntry> Entries {
get => _entries;
set {
var wasEmpty = _entries.Count == 0;
_entries = value != null ? value.ToList() : new List<ChronicleEntry>();
if (_entries.Count == 0) return;
// Jump to the last entry when first populating, or if not currently viewing
if (wasEmpty || !gameObject.activeSelf) {
if (!gameObject.activeSelf) {
_currentIndex = _entries.Count - 1;
ScrollToTop();
@@ -62,8 +60,6 @@ namespace eagle {
private const string TitleSplitPattern = @"\n\s*=====\s*\n";
private void SetUp() {
if (_entries.Count == 0) return;
previousButton.interactable = _currentIndex > 0;
nextButton.interactable = _currentIndex < _entries.Count - 1;
@@ -19,86 +19,35 @@ namespace eagle {
}
}
/// <summary>
/// Thread-safe provider for streaming LLM text content.
///
/// HandleNewStreamingText can be called from any thread (e.g., gRPC thread).
/// ProcessPendingUpdates must be called from the main thread (once per frame)
/// to notify listeners of changes.
/// </summary>
public class ClientTextProvider {
public static readonly ClientTextProvider Provider = new();
// Lock for thread-safe access to text dictionary
// Using a lock instead of ConcurrentDictionary because HandleNewStreamingText
// does a read-modify-write that must be atomic
private readonly object _lock = new();
private readonly Dictionary<String, TextEntry> _streamingTexts = new();
// Track which text IDs have pending updates
private readonly HashSet<String> _pendingUpdates = new();
// Listeners are only added/removed from main thread
private readonly HashSet<IClientTextListener> _listeners = new();
public void Clear() {
lock (_lock) {
_streamingTexts.Clear();
_pendingUpdates.Clear();
}
}
public void Clear() { _streamingTexts.Clear(); }
public Dictionary<String, TextEntry> All() {
lock (_lock) { return new Dictionary<string, TextEntry>(_streamingTexts); }
return new Dictionary<string, TextEntry>(_streamingTexts);
}
/// <summary>
/// Updates the text dictionary. Thread-safe - can be called from any thread.
/// Listeners are NOT notified here; call ProcessPendingUpdates from main thread.
/// </summary>
public String
HandleNewStreamingText(String llmId, String newText, Int32 knownByteCount, bool completed) {
lock (_lock) {
var currentText = "";
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
var currentText = "";
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
// Mark this text ID as having pending updates
_pendingUpdates.Add(llmId);
_listeners.Where(x => x.TextId() == llmId)
.ToList()
.ForEach(x => x.OnTextUpdate(updatedText, completed));
return updatedText;
}
}
/// <summary>
/// Process pending updates and notify listeners. Must be called from main thread.
/// This batches multiple updates to the same text ID into a single notification per frame.
/// </summary>
public void ProcessPendingUpdates() {
List<(String llmId, TextEntry entry)> updates;
lock (_lock) {
if (_pendingUpdates.Count == 0) return;
// Collect pending updates and their current values
updates = _pendingUpdates.Where(id => _streamingTexts.ContainsKey(id))
.Select(id => (id, _streamingTexts[id]))
.ToList();
_pendingUpdates.Clear();
}
// Notify listeners outside the lock to avoid potential deadlocks
foreach (var (llmId, textEntry) in updates) {
foreach (var listener in _listeners.Where(x => x.TextId() == llmId)) {
listener.OnTextUpdate(textEntry.Text, textEntry.Completed);
}
}
return updatedText;
}
public TextEntry GetTextEntry(string streamId) {
@@ -108,10 +57,7 @@ namespace eagle {
return new TextEntry(text, true);
}
lock (_lock) {
_streamingTexts.TryGetValue(streamId, out var entry);
return entry;
}
return _streamingTexts.GetValueOrDefault(streamId, null);
}
public void AddListener(IClientTextListener listener) {
@@ -124,4 +70,4 @@ namespace eagle {
public void RemoveListener(IClientTextListener listener) { _listeners.Remove(listener); }
}
}
}
@@ -224,14 +224,13 @@ namespace eagle {
tp => tp.TypeId == battalionTypeId && tp.MeetsRequirements);
}
private void MaybeActivateRow(
EventBasedTable table,
BattalionTypeId battalionTypeId,
int[] extraTroopsByType) {
private void MaybeActivateRow(EventBasedTable table, BattalionTypeId battalionTypeId) {
var parent = table.gameObject.transform.parent;
var allowed =
TypeIsAllowed(battalionTypeId) || extraTroopsByType[(int)battalionTypeId] > 0;
var allowed = TypeIsAllowed(battalionTypeId) ||
extraTroops.Where(tfb => tfb.type == battalionTypeId)
.Select(tfb => tfb.count)
.Sum() > 0;
table.gameObject.GetComponent<OrganizeExtrasTable>().Set(
allowed,
@@ -252,12 +251,12 @@ namespace eagle {
parent.GetComponentInChildren<RawImage>().color = color;
}
private void MaybeActivateRows(int[] extraTroopsByType) {
MaybeActivateRow(lightInfantryTable, BattalionTypeId.LightInfantry, extraTroopsByType);
MaybeActivateRow(heavyInfantryTable, BattalionTypeId.HeavyInfantry, extraTroopsByType);
MaybeActivateRow(longbowmenTable, BattalionTypeId.Longbowmen, extraTroopsByType);
MaybeActivateRow(dragoonsTable, BattalionTypeId.LightCavalry, extraTroopsByType);
MaybeActivateRow(knightsTable, BattalionTypeId.HeavyCavalry, extraTroopsByType);
private void MaybeActivateRows() {
MaybeActivateRow(lightInfantryTable, BattalionTypeId.LightInfantry);
MaybeActivateRow(heavyInfantryTable, BattalionTypeId.HeavyInfantry);
MaybeActivateRow(longbowmenTable, BattalionTypeId.Longbowmen);
MaybeActivateRow(dragoonsTable, BattalionTypeId.LightCavalry);
MaybeActivateRow(knightsTable, BattalionTypeId.HeavyCavalry);
}
protected override void SetUpUI() {
@@ -331,8 +330,7 @@ namespace eagle {
} else
return false;
// Note: We don't call Update() here - the caller (UpdateTable or MaxClickedImpl)
// will call it when needed. This avoids redundant recalculations.
eb.Update(existingBattalions);
return true;
}
@@ -369,6 +367,7 @@ namespace eagle {
}
// Remove original troops
else {
var updated = eb.Update(existingBattalions);
var availableToRemove = eb.Original.Size - eb.troopsRemoved;
var newlyRemovedCount = Math.Min(KeyModifiedAmount.Amount(), availableToRemove);
@@ -465,8 +464,7 @@ namespace eagle {
} else
return false;
// Note: We don't call Update() here - the caller (UpdateTable or MaxClickedImpl)
// will call it when needed. This avoids redundant recalculations.
newB.Update(existingBattalions);
return true;
}
@@ -641,6 +639,8 @@ namespace eagle {
}
public void UpdateTable() {
battalionsTable.RowCount = 0;
maxAllButton.gameObject.SetActive(false);
mergeButton.gameObject.SetActive(false);
@@ -653,30 +653,23 @@ namespace eagle {
{ BattalionTypeId.Longbowmen, 0 }
};
// Cache extra troop counts by type to avoid repeated LINQ queries
// Use array indexed by enum value for O(1) access without hashing
var battalionTypeCount = Enum.GetValues(typeof(BattalionTypeId)).Length;
var extraTroopsByType = new int[battalionTypeCount];
foreach (var et in extraTroops) { extraTroopsByType[(int)et.type] += et.count; }
maxAllButton.gameObject.SetActive(false);
foreach (var eb in existingBattalions) {
if (eb.dismissed) continue;
// Count total rows needed and set RowCount to reuse existing rows
var activeExisting = existingBattalions.Where(eb => !eb.dismissed).ToList();
var totalRows = activeExisting.Count + newBattalions.Count;
battalionsTable.RowCount = totalRows;
int rowIndex = 0;
foreach (var eb in activeExisting) {
var row = battalionsTable.ComponentAt<OrganizeTroopsTableRow>(rowIndex);
var newRow = battalionsTable.AddRowWithComponent<OrganizeTroopsTableRow>();
eb.Update(existingBattalions);
row.BattalionInfo = eb;
newRow.BattalionInfo = eb;
row.PlusButtonClickedCallback = () => PlusClicked(eb);
row.MinusButtonClickedCallback = () => MinusClicked(eb);
row.MaxButtonClickedCallback = () => MaxClicked(eb);
row.DismissButtonClickedCallback = () => DismissClicked(eb);
newRow.PlusButtonClickedCallback = () => PlusClicked(eb);
newRow.MinusButtonClickedCallback = () => MinusClicked(eb);
newRow.MaxButtonClickedCallback = () => MaxClicked(eb);
newRow.DismissButtonClickedCallback = () => DismissClicked(eb);
bool canAugment = TypeIsAllowed(eb.TypeId) || extraTroopsByType[(int)eb.TypeId] > 0;
row.CanAugment = canAugment;
bool canAugment =
TypeIsAllowed(eb.TypeId) ||
extraTroops.Where(tfb => tfb.type == eb.TypeId).Sum(tfb => tfb.count) > 0;
newRow.CanAugment = canAugment;
if (eb.Count < eb.Capacity) {
// Enable MaxAll button if we could add new troops to this battalion type
@@ -688,19 +681,18 @@ namespace eagle {
mergeButton.gameObject.SetActive(true);
}
}
rowIndex++;
}
foreach (var newB in newBattalions) {
var row = battalionsTable.ComponentAt<OrganizeTroopsTableRow>(rowIndex);
var newRow = battalionsTable.AddRowWithComponent<OrganizeTroopsTableRow>();
newB.Update(existingBattalions);
row.BattalionInfo = newB;
newRow.BattalionInfo = newB;
row.PlusButtonClickedCallback = () => PlusClicked(newB);
row.MinusButtonClickedCallback = () => MinusClicked(newB);
row.MaxButtonClickedCallback = () => MaxClicked(newB);
row.DismissButtonClickedCallback = () => DismissClicked(newB);
newRow.PlusButtonClickedCallback = () => PlusClicked(newB);
newRow.MinusButtonClickedCallback = () => MinusClicked(newB);
newRow.MaxButtonClickedCallback = () => MaxClicked(newB);
newRow.DismissButtonClickedCallback = () => DismissClicked(newB);
if (newB.Count < newB.Capacity) {
maxAllButton.gameObject.SetActive(true);
@@ -709,7 +701,6 @@ namespace eagle {
mergeButton.gameObject.SetActive(true);
}
}
rowIndex++;
}
lightInfantryTable.RowCount = 0;
@@ -748,18 +739,15 @@ namespace eagle {
if (!sufficient) { _disabledReason = "Not enough gold"; }
// Also check that something has changed
// Check newBattalion fields directly instead of calling Update() which is expensive
bool somethingChanged =
(newBattalions.Exists(
b => b.newBattalion.NewTroops > 0 ||
b.newBattalion.TroopsFromOtherBattalion.Count > 0) ||
(newBattalions.Exists(b => b.Update(existingBattalions).Size > 0) ||
existingBattalions.Exists(eb => eb.changed != null || eb.troopsRemoved > 0));
if (!somethingChanged) { _disabledReason = "No battalions have changed"; }
_enableCommit = sufficient && somethingChanged;
resetAllButton.gameObject.SetActive(somethingChanged);
MaybeActivateRows(extraTroopsByType);
MaybeActivateRows();
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -120,19 +120,6 @@ namespace eagle {
}
}
/// <summary>
/// Force the circuit breaker to allow an immediate reconnect attempt.
/// Resets the state to HalfOpen so the next connection will be a test.
/// </summary>
public void ForceReconnect() {
lock (this) {
if (_state == State.Open) {
_state = State.HalfOpen;
_logger.LogLine("[CIRCUIT] OPEN → HALF_OPEN (forced by user)");
}
}
}
/// <summary>
/// Get human-readable status for UI display.
/// </summary>
@@ -151,7 +138,7 @@ namespace eagle {
var timeUntilTest = OpenTimeoutSeconds -
(DateTime.UtcNow - _openedAt.Value).TotalSeconds;
if (timeUntilTest > 0) {
return $"Server unavailable. Retrying in {Math.Min(10, (int)timeUntilTest)}s";
return $"Server unavailable. Retrying in {(int)timeUntilTest}s";
}
}
return "Server unavailable. Testing...";
@@ -2,7 +2,6 @@ using System;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
/// <summary>
@@ -27,9 +26,6 @@ namespace eagle {
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
[Tooltip("Optional button to force immediate reconnection when server is down")]
public Button retryButton;
// Update interval in seconds
private const float UpdateInterval = 0.5f;
private float _timeSinceLastUpdate = 0f;
@@ -43,10 +39,9 @@ namespace eagle {
return;
}
if (retryButton != null) {
retryButton.onClick.AddListener(OnRetryClicked);
retryButton.gameObject.SetActive(false);
}
// Find the PersistentClientConnection - this assumes it's accessible
// In production, this would need proper dependency injection
// For now, the connection will be set externally or found via another method
}
/// <summary>
@@ -78,12 +73,11 @@ namespace eagle {
// Check circuit breaker state first - it takes precedence
var circuitState = _connection.CircuitBreaker.CurrentState;
if (circuitState == ConnectionCircuitBreaker.State.Open) {
SetRetryButtonVisible(true);
var nextTest = _connection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
int seconds = (int)Math.Ceiling(timeUntilTest.TotalSeconds);
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
return;
@@ -92,7 +86,6 @@ namespace eagle {
_textComponent.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
_textComponent.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -101,11 +94,6 @@ namespace eagle {
var state = _connection.CurrentState;
var nextAttempt = _connection.NextReconnectAttempt;
// Show retry button if we're counting down to a reconnect attempt
bool isCountingDown = state == ConnectionState.Reconnecting && nextAttempt.HasValue &&
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string statusText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
@@ -154,16 +142,8 @@ namespace eagle {
return "<color=yellow>●</color> Reconnecting...";
}
int secondsRemaining = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
int secondsRemaining = (int)Math.Ceiling(timeUntilRetry.TotalSeconds);
return $"<color=orange>●</color> Retry in {secondsRemaining}s";
}
private void SetRetryButtonVisible(bool visible) {
if (retryButton != null) { retryButton.gameObject.SetActive(visible); }
}
private void OnRetryClicked() {
if (_connection != null) { _connection.ForceReconnect(); }
}
}
}
@@ -188,9 +188,6 @@ namespace eagle {
}
void Update() {
// Process batched streaming text updates (thread-safe, once per frame)
ClientTextProvider.Provider.ProcessPendingUpdates();
ArrangeLayout();
if (_newModel != null) { SwapModel(); }
@@ -229,11 +226,12 @@ namespace eagle {
void OnApplicationPause(bool pause) {
if (ModelUpdater == null) { return; }
// Don't unsubscribe on pause - this caused a race condition where the subscriber
// could be lost if pause happened before the async StartListeningForUpdates completed.
// With MainQueue rate-limiting, keeping the subscription during pause is safe.
// Reconnects will continue to work, and updates will queue up and be processed on
// resume.
if (pause) {
ModelUpdater.StopListeningForUpdates();
} else {
// Fire-and-forget - subscription is awaited internally and failures are logged
_ = ModelUpdater.StartListeningForUpdates();
}
}
#if UNITY_EDITOR
@@ -43,7 +43,7 @@ namespace eagle {
TokenId? CommandToken { get; }
TokenId LastPostedToken { get; }
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
Dictionary<String, ShardokGameModel> ShardokGameModels { get; }
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
@@ -105,12 +105,9 @@ namespace eagle {
_currentModel.ShardokGameModels
.Select(sgm => {
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
// Use thread-safe count from gRPC thread updates. Fall back to 0 if
// not yet tracked (avoids accessing non-thread-safe History.Count).
var count = _shardokResultCounts.GetValueOrDefault(sgm.Key, 0);
return new IClientConnectionSubscriber.ShardokViewStatus {
shardokGameId = sgm.Key,
filteredResultCount = needsResync ? 0 : count,
filteredResultCount = needsResync ? 0 : sgm.Value.History.Count(),
requestFullResync = needsResync
};
})
@@ -145,11 +142,6 @@ namespace eagle {
private readonly ConcurrentDictionary<string, bool> _shardokNeedsResync =
new ConcurrentDictionary<string, bool>();
// Thread-safe Shardok result counts: updated from gRPC thread via UpdateResultCounts
// Used by ShardokViewStatuses to report accurate counts even when MainQueue is blocked
private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
new ConcurrentDictionary<string, int>();
private readonly RollFetcher _rollFetcher;
// State synced with server
@@ -198,8 +190,7 @@ namespace eagle {
return new List<AvailableCommand>();
}
// Thread-safe: accessed from heartbeat timer thread via ShardokViewStatuses
public ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
public Dictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
public FactionView MaybeDestroyedFaction(FactionId factionId) {
if (ActiveFactions.TryGetValue(factionId, out var factionView)) {
@@ -241,8 +232,7 @@ namespace eagle {
new Dictionary<ProvinceId, OneProvinceAvailableCommands>();
_currentModel.GsView = new GameStateView();
_currentModel.ShardokGameModels =
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.ShardokGameModels = new Dictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
@@ -255,9 +245,9 @@ namespace eagle {
if (battleView == null) {
// Battle was removed (e.g., it ended) before we could create the model.
// This is expected when Eagle's RemovedBattleIds update arrives before a
// pending Shardok update - the UI already shows "Back to Eagle" via
// MarkBattleEnded(), so we just skip this stale update.
// This can happen due to race conditions between Eagle and Shardok updates.
Debug.LogWarning(
$"Cannot create ShardokGameModel for {shardokGameId}: battle not found in ShardokBattles (likely already ended)");
return null;
}
@@ -313,10 +303,8 @@ namespace eagle {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
}
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
// race conditions where a backlogged MainQueue update overwrites a newer count.
_lastUnfilteredResultCount =
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
updateItem.ActionResultResponse.AvailableCommands == null ||
updateItem.ActionResultResponse.AvailableCommands.Token !=
@@ -342,10 +330,7 @@ namespace eagle {
// Battle may have ended before we could create the model - remove
// any stale reference and skip this update
if (shardokGameModel == null) {
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
continue;
}
}
@@ -373,10 +358,7 @@ namespace eagle {
shardokGameModel;
} else {
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
}
}
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
@@ -411,12 +393,10 @@ namespace eagle {
}
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
foreach (var response in update.ShardokActionResultResponse
.ShardokGameResponses) {
_shardokResultCounts[response.ShardokGameId] = response.NewResultViewCount;
}
break;
// Note: Shardok counts are tracked in ShardokGameModel.History.Count
// which is updated in ReceiveGameUpdate on the main thread.
// For now, Shardok reconnects may still get duplicate data, but
// the primary issue (Eagle duplicates) is fixed here.
}
}
@@ -485,31 +465,6 @@ namespace eagle {
_currentModel.BattalionTypes =
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
// For any outstanding battles not in ShardokGameModels, create models and mark for
// resync. This ensures fresh clients get Shardok state for ongoing battles.
bool needsResubscribe = false;
foreach (var battle in _currentModel.ShardokBattles) {
if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
var model = MakeGameModel(battle.ShardokGameId);
if (model != null) {
_currentModel.ShardokGameModels[battle.ShardokGameId] = model;
MarkShardokForResync(battle.ShardokGameId);
_connectionLogger.LogLine(
$"[STATE_RESYNC] Created ShardokGameModel for outstanding battle {battle.ShardokGameId}");
needsResubscribe = true;
}
}
}
// If we created new ShardokGameModels, re-subscribe to request their full state.
// The original subscribe didn't include these battles since we didn't know about them
// yet.
if (needsResubscribe) {
_connectionLogger.LogLine(
"[STATE_RESYNC] Re-subscribing to request full Shardok state for new battles");
_ = StartListeningForUpdates();
}
}
private void HandleUpdates(List<ActionResultView> results) {
@@ -807,15 +762,6 @@ namespace eagle {
foreach (ShardokBattleView bv in entry.NewBattles) _currentModel.ShardokBattles.Add(bv);
foreach (string rb in entry.RemovedBattleIds) {
// If there's an active ShardokGameModel for this battle, mark it as ended
// so the UI knows to return to Eagle. This handles the race condition where
// the Eagle update removing the battle arrives before the Shardok Victory update.
if (_currentModel.ShardokGameModels.TryGetValue(rb, out var sgm)) {
sgm.MarkBattleEnded("Battle has ended.");
_currentModel.ShardokGameModels.TryRemove(rb, out _);
}
_shardokResultCounts.TryRemove(rb, out _);
for (int i = 0; i < _currentModel.ShardokBattles.Count; i++) {
if (_currentModel.ShardokBattles[i].ShardokGameId == rb) {
_currentModel.ShardokBattles.RemoveAt(i);
@@ -18,11 +18,10 @@ namespace eagle {
if (scrollRect) { scrollRect.normalizedPosition = new Vector2(0, 1); }
}
// Always update the view when TextId changes to clear any stale text
if (!String.IsNullOrEmpty(_textId)) {
// If the text ID is set, we want to update the view immediately
// to reflect any existing text.
OnTextUpdate(ClientTextProvider.Provider.GetTextEntry(TextId));
} else {
UpdateView();
}
}
}
@@ -60,14 +59,7 @@ namespace eagle {
}
private void OnTextUpdate(TextEntry entry) {
if (entry != null) {
OnTextUpdate(entry.Text, entry.Completed);
} else {
// Entry doesn't exist yet - clear text and update view to avoid stale content
_currentText = "";
_currentCompleted = false;
UpdateView();
}
if (entry != null) OnTextUpdate(entry.Text, entry.Completed);
}
public void OnTextUpdate(string text, bool completed) {
@@ -132,10 +132,7 @@ namespace eagle {
public void ProvinceHovered(ProvinceId? pid) {
// Highlight moving armies table
// Check row count to avoid index out of range if data changed after table was built
var rowCount = movingArmiesTable.RowCount;
MovingArmies.Each((army, i) => {
if (i >= rowCount) return;
var row = movingArmiesTable.ComponentAt<MovingArmyTableRow>(i);
if (army.DestinationProvinceId == pid || army.OriginProvinceId == pid) {
row.ShadeOn();
@@ -17,10 +17,6 @@ namespace eagle {
private readonly Queue<Notification> _notes = new();
// Incremented when DismissAll is clicked; pending AddNote calls check this
// to skip adding if a dismiss happened since they were enqueued
private int _dismissGeneration = 0;
private void SetPopupInfos() {
PopupInfos = _notes.Select(note => new PopupInfo {
titleText = note.Title,
@@ -58,13 +54,7 @@ namespace eagle {
string llmId,
List<ProvinceId> provinceIds,
List<HeroView> displayedHeroes) {
// Capture current generation - if DismissAll is clicked before this executes,
// we'll skip adding the note
var capturedGeneration = _dismissGeneration;
MainQueue.Q.Enqueue(() => {
// Skip if DismissAll was clicked since this was enqueued
if (capturedGeneration != _dismissGeneration) return;
var existingNote = _notes.FirstOrDefault(
n => n.Title == title &&
HeroListsMatch(n.DisplayedHeroes, displayedHeroes));
@@ -89,8 +79,6 @@ namespace eagle {
}
public void DismissAllClicked() {
// Increment generation immediately so pending AddNote calls will skip
_dismissGeneration++;
_notes.Clear();
SetPopupInfos();
}
@@ -1,106 +1,30 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using ProvinceId = Int32;
using HeroId = Int32;
public static class ProfessionGainedDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
private static readonly Random Random = new();
private static readonly Dictionary<Profession, string> ProfessionNames =
new Dictionary<Profession, string> {
{ Profession.Mage, "Mage" },
{ Profession.Necromancer, "Necromancer" },
{ Profession.Engineer, "Engineer" },
{ Profession.Paladin, "Paladin" },
{ Profession.Ranger, "Ranger" },
{ Profession.Champion, "Champion" }
};
private static readonly Dictionary<Profession, string> ProfessionNames = new() {
{ Profession.Mage, "Mage" },
{ Profession.Necromancer, "Necromancer" },
{ Profession.Engineer, "Engineer" },
{ Profession.Paladin, "Paladin" },
{ Profession.Ranger, "Ranger" },
{ Profession.Champion, "Champion" }
};
private static readonly Dictionary<Profession, List<string>> ProfessionTitles = new() {
{ Profession.Mage,
new List<string> {
"Arcane Awakening",
"The Gift Revealed",
"Touched by Magic",
"Mystical Ascension",
"Power Unbound",
"The Arcane Path",
"Secrets of the Weave",
"Spellborn",
"Wielder of the Unseen",
"Flames of Knowledge"
} },
{ Profession.Necromancer,
new List<string> {
"Dark Pact Sealed",
"Beyond the Veil",
"Death's Apprentice",
"Whispers from Beyond",
"The Forbidden Art",
"Shadow Covenant",
"Secrets of the Grave",
"Communion with Darkness",
"The Deathless Path",
"Embrace of Shadow"
} },
{ Profession.Engineer,
new List<string> {
"Genius Unleashed",
"Master of Mechanisms",
"The Inventor's Spark",
"Gears of Progress",
"Mind of Innovation",
"Builder of Wonders",
"The Tinkerer's Art",
"Siege Mastery",
"Architect of War",
"Mechanical Brilliance"
} },
{ Profession.Paladin,
new List<string> {
"Divine Calling",
"Holy Vows Taken",
"Blessed Champion",
"The Righteous Path",
"Shield of the Faith",
"Anointed Warrior",
"Oath of Light",
"Heaven's Chosen",
"Sacred Duty",
"Defender of the Realm"
} },
{ Profession.Ranger,
new List<string> {
"One with the Wild",
"Voice of the Forest",
"The Untamed Path",
"Nature's Guardian",
"Shadow of the Woods",
"Hunter's Instinct",
"Wild Heart",
"The Wanderer's Way",
"Beast Companion",
"Eyes of the Hawk"
} },
{ Profession.Champion,
new List<string> {
"Born for Battle",
"Blade Mastery",
"The Warrior's Edge",
"Forged in Combat",
"Unmatched Prowess",
"Heart of Steel",
"The Victor's Path",
"Legend in the Making",
"Master of Arms",
"Glory Awaits"
} }
};
private static readonly Dictionary<Profession, string> ProfessionTitles =
new Dictionary<Profession, string> {
{ Profession.Mage, "Arcane Awakening" },
{ Profession.Necromancer, "Dark Pact Sealed" },
{ Profession.Engineer, "Genius Unleashed" },
{ Profession.Paladin, "Divine Calling" },
{ Profession.Ranger, "One with the Wild" },
{ Profession.Champion, "Born for Battle" }
};
private static string GetProfessionName(Profession profession) {
return ProfessionNames.TryGetValue(profession, out var name) ? name : "Unknown";
@@ -116,19 +40,8 @@ namespace eagle.Notifications.ARNNotifications {
}
private static string GetProfessionTitle(Profession profession) {
if (ProfessionTitles.TryGetValue(profession, out var titles) && titles.Count > 0) {
return titles[Random.Next(titles.Count)];
}
return "Profession Gained";
}
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
foreach (var province in model.Provinces.Values) {
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
return province.Id;
}
}
return null;
return ProfessionTitles.TryGetValue(profession, out var title) ? title
: "Profession Gained";
}
private static IEnumerable<Notification> GenerateNotifications(
@@ -136,38 +49,14 @@ namespace eagle.Notifications.ARNNotifications {
IGameModel currentModel) {
var details = notification.Details.ProfessionGainedDetails;
var hero = currentModel.Heroes[details.HeroId];
var factionName = currentModel.FactionName(details.FactionId);
var professionName = GetProfessionName(details.NewProfession);
var article = GetArticle(professionName);
string textTemplate;
List<ProvinceId> affectedProvinces;
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
if (details.FactionId == currentModel.PlayerId) {
// Player's own hero - vary text based on faction leader status
string heroDescription;
if (hero.IsFactionLeader) {
heroDescription =
$"Your sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
} else {
heroDescription = "Your vassal";
}
var heroProvinceId = FindProvinceForHero(details.HeroId, currentModel);
if (heroProvinceId.HasValue) {
var provinceName = currentModel.Provinces[heroProvinceId.Value].Name;
textTemplate =
$"{heroDescription} {{Hero}} in {provinceName} became {article} {professionName}.\n\n";
affectedProvinces = new List<ProvinceId> { heroProvinceId.Value };
} else {
textTemplate =
$"{heroDescription} {{Hero}} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
} else {
// Another faction's hero
var factionName = currentModel.FactionName(details.FactionId);
textTemplate = $"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
string textTemplate =
$"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "Hero", (hero.NameTextId, "A hero") }
@@ -31,13 +31,7 @@ namespace eagle.Notifications.ARNNotifications {
};
if (playerId.HasValue && playerId.Value == paidToFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
textTemplate: $"We have accepted the ransom from {currentModel.FactionName(paidByFactionId)} for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead });
// no notification
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
@@ -9,7 +9,6 @@ namespace eagle.Notifications {
private List<GeneratedTextListener> textListeners = new();
private string textTemplate;
private Dictionary<string, string> placeholderValues = new();
private Dictionary<string, string> fallbackValues = new();
public DynamicTextNotification(
string title,
@@ -73,9 +72,6 @@ namespace eagle.Notifications {
string nameTextId = kvp.Value.nameTextId;
string fallback = kvp.Value.fallback;
// Store fallback so UpdateText can use it for missing placeholders
fallbackValues[placeholder] = fallback;
if (!string.IsNullOrEmpty(nameTextId)) {
var listener = new GeneratedTextListener(
nameTextId,
@@ -99,15 +95,6 @@ namespace eagle.Notifications {
private void UpdateText() {
string result = textTemplate;
// First apply fallbacks for any placeholder without a loaded value
foreach (var kvp in fallbackValues) {
if (!placeholderValues.ContainsKey(kvp.Key)) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
}
// Then apply actual loaded values
foreach (var kvp in placeholderValues) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
@@ -69,17 +69,6 @@ namespace eagle {
private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
public ConnectionCircuitBreaker CircuitBreaker => _circuitBreaker;
/// <summary>
/// Force an immediate reconnection attempt, bypassing the circuit breaker timeout.
/// </summary>
public void ForceReconnect() {
_circuitBreaker.ForceReconnect();
_retryTimer?.Dispose();
NextReconnectAttempt = null;
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
Task.Run(() => Connect());
}
private DateTime? GetDeadlineFromNow() {
return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
}
@@ -237,10 +226,12 @@ namespace eagle {
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
$"confirmedResultCount={ack.ConfirmedResultCount}");
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
// AFTER updates are actually received, not here. This ensures that if the
// connection drops between acknowledgment and update delivery, the resync
// will be requested again on the next reconnect.
// Clear resync flags ONLY after successful acknowledgment
if (subscriber is GameModelUpdater updater) {
foreach (var status in shardokStatuses.Where(s => s.requestFullResync)) {
updater.ClearShardokResyncFlag(status.shardokGameId);
}
}
return true;
} else {
@@ -712,23 +703,9 @@ namespace eagle {
});
break;
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
// Handle streaming text directly on gRPC thread - dictionary is thread-safe.
// Listeners are notified later via ProcessPendingUpdates() on main thread.
var str = gameUpdate.StreamingTextResponse;
if (str != null) {
ClientTextProvider.Provider.HandleNewStreamingText(
str.LlmIdentifier,
str.NewText,
str.StartingByteCount,
str.Completed);
}
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
// notification
break;
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
// This ensures reconnects use accurate counts even when MainQueue is blocked
// (e.g., when Unity is backgrounded).
@@ -1071,69 +1048,51 @@ namespace eagle {
}
}
// Grace period after connect before sync mismatch triggers reconnect.
// Allows time to receive and process historical results after fresh subscribe.
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
// Check for sync mismatches reported by server
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
hasMismatch = true;
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
if (response.GameSyncResults.Count > 0) {
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
hasMismatch = true;
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
}
}
}
}
if (!hasMismatch) return;
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Grace period after connect - allow time to receive historical results
if (_lastSuccessfulConnect.HasValue) {
var timeSinceConnect =
(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds;
if (timeSinceConnect < SyncMismatchGracePeriodSeconds) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Ignoring mismatch during grace period ({timeSinceConnect:F1}s < {SyncMismatchGracePeriodSeconds}s)");
return;
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;
using Debug = UnityEngine.Debug;
public class MainQueue : MonoBehaviour {
static MainQueue __singletonInstance;
private readonly Queue<Action> _actionQueue = new();
private readonly Queue<Action> _nextUpdateQueue = new();
// Time budget per frame to prevent blocking when resuming from background
// 8ms leaves room for rendering within a 16ms (60fps) frame budget
private const long MaxMillisecondsPerFrame = 8;
// Limit actions per frame to prevent blocking when resuming from background
private const int MaxActionsPerFrame = 10;
// Track queue depth for logging
private int _lastLoggedQueueDepth = 0;
@@ -24,29 +21,19 @@ public class MainQueue : MonoBehaviour {
// Update is called once per frame
void Update() {
int actionsProcessed = 0;
int queueDepthBefore;
lock (_actionQueue) { queueDepthBefore = _actionQueue.Count; }
// Fast path: skip processing if queue is empty
if (queueDepthBefore == 0) {
lock (_nextUpdateQueue) {
if (_nextUpdateQueue.Count > 0) {
foreach (Action action in _nextUpdateQueue) { Enqueue(action); }
_nextUpdateQueue.Clear();
}
}
return;
}
// Log when queue has built up (e.g., after resuming from background)
if (queueDepthBefore > 100 && queueDepthBefore != _lastLoggedQueueDepth) {
if (queueDepthBefore > MaxActionsPerFrame && queueDepthBefore != _lastLoggedQueueDepth) {
Debug.Log($"[MainQueue] Processing backlog: {queueDepthBefore} actions queued");
_lastLoggedQueueDepth = queueDepthBefore;
} else if (queueDepthBefore <= 100) {
} else if (queueDepthBefore <= MaxActionsPerFrame) {
_lastLoggedQueueDepth = 0;
}
var stopwatch = Stopwatch.StartNew();
Action possibleAction;
do {
possibleAction = null;
@@ -54,8 +41,11 @@ public class MainQueue : MonoBehaviour {
if (_actionQueue.Count > 0) { possibleAction = _actionQueue.Dequeue(); }
}
if (possibleAction != null) { possibleAction.Invoke(); }
} while (possibleAction != null && stopwatch.ElapsedMilliseconds < MaxMillisecondsPerFrame);
if (possibleAction != null) {
possibleAction.Invoke();
actionsProcessed++;
}
} while (possibleAction != null && actionsProcessed < MaxActionsPerFrame);
lock (_nextUpdateQueue) {
foreach (Action action in _nextUpdateQueue) { Enqueue(action); }
@@ -1,7 +1,6 @@
using System;
using System.Diagnostics;
using System.IO;
using common;
using eagle;
using Net.Eagle0.Shardok.Api;
using TMPro;
@@ -28,9 +27,6 @@ public class SettingsPanelController : MonoBehaviour {
public Toggle tooltipHugsCursorToggle;
public HoveringTooltip hoveringTooltip;
public Slider autoScrollSpeedSlider;
public TMP_Text autoScrollSpeedLabel;
public HexGrid hexGrid;
private bool _active = false;
@@ -58,11 +54,6 @@ public class SettingsPanelController : MonoBehaviour {
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
if (autoScrollSpeedSlider != null) {
autoScrollSpeedSlider.value = AutoScrollingText.GlobalScrollSpeedMultiplier;
UpdateAutoScrollSpeedLabel(autoScrollSpeedSlider.value);
}
}
// Update is called once per frame
@@ -116,21 +107,6 @@ public class SettingsPanelController : MonoBehaviour {
hoveringTooltip.HugsCursor = val;
}
public void OnAutoScrollSpeedSliderChange(float val) {
AutoScrollingText.GlobalScrollSpeedMultiplier = val;
UpdateAutoScrollSpeedLabel(val);
}
private void UpdateAutoScrollSpeedLabel(float val) {
if (autoScrollSpeedLabel != null) {
if (val < 0.01f) {
autoScrollSpeedLabel.text = "Paused";
} else {
autoScrollSpeedLabel.text = $"{val:F2}x";
}
}
}
public void OnResourcesFolderClick() {
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
}
@@ -22,9 +22,6 @@ public class HexMesh : MonoBehaviour {
}
public void Triangulate(IEnumerable<HexCell> cells) {
// Guard against Update() being called before SetUp() initializes hexMesh
if (hexMesh == null) return;
hexMesh.Clear();
vertices.Clear();
triangles.Clear();
@@ -385,99 +385,98 @@ namespace Shardok {
Model.MyUncommittedUnits.Where(uv => uv.Location.Row == -1).ToList();
}
/// <summary>
/// Called when the ShardokGameModel is updated. This is invoked from UpdateAction,
/// which is called from ShardokGameModel.HandleUpdates, which runs on MainQueue.
/// No need to re-enqueue - we're already on the main thread.
/// </summary>
void ModelUpdated() {
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
MainQueue.Q.Enqueue(() => {
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
HandleEnemyStartingPositionOverlays();
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
endTurnButton.interactable = false;
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Too many units";
endTurnButton.interactable = false;
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"Too many units";
endTurnButton.interactable = false;
} else {
endTurnButton.interactable = true;
}
SetDisplayedCommandGroup(0);
} else {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
}
SetDisplayedCommandGroup(0);
SelectAppropriateDefaultCommand();
} else {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
}
SelectAppropriateDefaultCommand();
} else {
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
}
}
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
}
SetupArmiesTable();
SetupArmiesTable();
});
}
private void SetupArmiesTable() {
@@ -708,33 +707,34 @@ namespace Shardok {
}
void SetModifiers() {
// Called from ModelUpdated which runs on MainQueue - no need to re-enqueue
hexGrid.ClearCellModifierImages();
MainQueue.Q.Enqueue(() => {
hexGrid.ClearCellModifierImages();
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
}
}
}
}
});
}
void HandleButton() {
@@ -961,7 +961,7 @@ namespace Shardok {
commandTypeUIManager.CommandGroupForType(command.Type));
}
if (allCommands.Any() && mapMouseCoords != null) {
if (allCommands.Any()) {
var meleeCommands = allCommands.Where(
command => CommandTypeUIManager.MeleeAttackGroup ==
commandTypeUIManager.CommandGroupForType(command.Type));
@@ -82,7 +82,7 @@ public class ShardokGameModel {
public int Month { get; private set; }
private List<PlayerTotals> PlayerTotals { get; set; }
public List<CommandDescriptor> AvailableCommands { get; private set; } = new();
public List<CommandDescriptor> AvailableCommands { get; private set; }
public Action UpdateAction { get; set; }
public List<PlayerWithHostility> players;
public String GetPlayerName(PlayerId playerId) {
@@ -111,17 +111,6 @@ public class ShardokGameModel {
public bool InSetUp => GameStatus != null && GameStatus.State == GameStatus.Types.State.SetUp;
/// <summary>
/// Mark this battle as ended (called when battle is removed from Eagle before
/// we receive the final Shardok update, e.g., due to race conditions when
/// Unity was backgrounded).
/// </summary>
public void MarkBattleEnded(string reason) {
GameStatus =
new GameStatus { State = GameStatus.Types.State.Victory, Description = reason };
UpdateAction?.Invoke();
}
private readonly PersistentClientConnection _persistentClientConnection;
private const int StartingHistoryCapacity = 100;
@@ -217,8 +206,6 @@ public class ShardokGameModel {
? newCommands.CurrentCommand.ToList()
: newCommands.PreviewCommand.ToList();
}
// Trigger UI refresh after commands are updated so unit action indicators reflect new state
UpdateAction?.Invoke();
}
public bool HasTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
@@ -1,278 +0,0 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace common {
/// <summary>
/// Automatically scrolls text content that overflows its container.
/// Shows a fade gradient at the bottom when content overflows, then begins
/// auto-scrolling after a delay. Useful for tooltips where the user can't
/// manually scroll.
///
/// Also supports dynamic height sizing: the ScrollRect will grow to fit its
/// content up to available screen space, only enabling scrolling when needed.
/// </summary>
public class AutoScrollingText : MonoBehaviour {
/// <summary>
/// Global scroll speed multiplier controlled by Settings.
/// Default is 1.0 (use instance scrollSpeed as-is).
/// Range: 0.0 (paused) to 2.0 (double speed).
/// </summary>
public static float GlobalScrollSpeedMultiplier {
get => PlayerPrefs.GetFloat(ScrollSpeedKey, 1.0f);
set => PlayerPrefs.SetFloat(ScrollSpeedKey, Mathf.Clamp(value, 0f, 2f));
}
private const string ScrollSpeedKey = "autoScrollSpeedMultiplier";
[Header("Scroll Rect")]
[Tooltip("The ScrollRect containing the text content")]
public ScrollRect scrollRect;
[Tooltip("Optional gradient image to show when content overflows (fades at bottom)")]
public GameObject fadeGradient;
[Header("Auto-Scroll Timing")]
[Tooltip("Seconds to wait before starting to scroll")]
public float scrollDelaySeconds = 1.5f;
[Tooltip("Scroll speed in normalized units per second (0-1 range)")]
public float scrollSpeed = 0.15f;
[Tooltip("Seconds to pause at the bottom before resetting")]
public float pauseAtBottomSeconds = 1.0f;
[Header("Dynamic Sizing")]
[Tooltip("If true, resize the ScrollRect to fit content up to available space")]
public bool dynamicHeight = true;
[Tooltip("Margin from top of screen in pixels")]
public float topMargin = 20f;
[Tooltip("LayoutElement to adjust for dynamic height (usually on ScrollRect)")]
public LayoutElement layoutElement;
[Tooltip("Other content in the panel (e.g., hero details). Height will be preserved.")]
public RectTransform otherContent;
[Tooltip("Optional: Panel to hide until layout is complete (prevents jumpy resize)")]
public CanvasGroup panelCanvasGroup;
private float _visibleTime = 0f;
private bool _isScrolling = false;
private bool _isPausedAtBottom = false;
private float _pauseTimer = 0f;
private RectTransform _scrollRectTransform;
private Canvas _rootCanvas;
private void OnEnable() {
// Hide panel until layout is complete to prevent jumpy resize
if (panelCanvasGroup != null) { panelCanvasGroup.alpha = 0f; }
if (scrollRect != null) {
_scrollRectTransform = scrollRect.GetComponent<RectTransform>();
_rootCanvas = scrollRect.GetComponentInParent<Canvas>()?.rootCanvas;
// Immediately reset scroll position to prevent visual jump on first frame
// Use normalizedPosition to reset both horizontal and vertical
scrollRect.normalizedPosition = new Vector2(0f, 1f);
// Also reset the content's anchored position to prevent any offset
if (scrollRect.content != null) {
var contentRect = scrollRect.content;
contentRect.anchoredPosition = new Vector2(0f, contentRect.anchoredPosition.y);
}
}
// Delay layout update to next frame so content has time to rebuild
StartCoroutine(DelayedInitialize());
}
private System.Collections.IEnumerator DelayedInitialize() {
yield return null; // Wait one frame for layout to rebuild
yield return null; // Extra frame for ContentSizeFitter
UpdateDynamicHeight();
ResetScroll();
// Show panel now that layout is complete
if (panelCanvasGroup != null) { panelCanvasGroup.alpha = 1f; }
}
private void OnDisable() { ResetScroll(); }
/// <summary>
/// Resets scroll position to top and restarts the delay timer.
/// Call this when the text content changes.
/// </summary>
public void ResetScroll() {
_visibleTime = 0f;
_isScrolling = false;
_isPausedAtBottom = false;
_pauseTimer = 0f;
if (scrollRect != null) {
scrollRect.verticalNormalizedPosition = 1f; // Top
}
UpdateFadeGradient();
}
[Header("Debug")]
[Tooltip("Enable debug logging to console")]
public bool debugLogging = false;
/// <summary>
/// Updates the ScrollRect height to fit content, up to available screen space.
/// </summary>
private void UpdateDynamicHeight() {
if (!dynamicHeight || layoutElement == null || scrollRect == null) return;
if (_scrollRectTransform == null || _rootCanvas == null) return;
// Get content height - prefer TMP_Text.preferredHeight over rect.height
// because rect.height may not reflect actual text size without ContentSizeFitter
float contentHeight = 0f;
float rectHeight = scrollRect.content != null ? scrollRect.content.rect.height : 0f;
var tmpText = scrollRect.content?.GetComponentInChildren<TMP_Text>();
if (tmpText != null) {
// Use TMP's calculated preferred height - this is the actual text size
contentHeight = tmpText.preferredHeight;
// Sync Content RectTransform height with actual text size.
// Must both grow AND shrink to prevent blank space when scrolling to bottom.
if (Mathf.Abs(scrollRect.content.rect.height - contentHeight) > 1f) {
scrollRect.content.SetSizeWithCurrentAnchors(
RectTransform.Axis.Vertical,
contentHeight);
}
} else {
contentHeight = rectHeight;
}
// Get the bottom of the ScrollRect in screen space
Vector3[] corners = new Vector3[4];
_scrollRectTransform.GetWorldCorners(corners);
Camera cam = _rootCanvas.renderMode == RenderMode.ScreenSpaceOverlay
? null
: _rootCanvas.worldCamera;
Vector2 scrollRectBottomScreen =
RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
// Calculate how much space we have from ScrollRect bottom to top of screen
float spaceToTop = Screen.height - scrollRectBottomScreen.y - topMargin;
// Account for canvas scaling
float scaleFactor = _rootCanvas.scaleFactor;
if (scaleFactor > 0) { spaceToTop /= scaleFactor; }
// Get height of other content (hero details) if specified
float otherContentHeight = 0f;
if (otherContent != null) { otherContentHeight = otherContent.rect.height; }
// Max height for ScrollRect = space to top minus other content
float maxScrollRectHeight = spaceToTop - otherContentHeight;
// Clamp to reasonable bounds
maxScrollRectHeight = Mathf.Max(maxScrollRectHeight, 50f);
// Set preferred height to content or max, whichever is smaller
float targetHeight = Mathf.Min(contentHeight, maxScrollRectHeight);
layoutElement.preferredHeight = targetHeight;
if (debugLogging) {
Debug.Log(
$"[AutoScrollingText] contentHeight={contentHeight:F0} (rectHeight={rectHeight:F0}), " +
$"spaceToTop={spaceToTop:F0}, otherContent={otherContentHeight:F0}, " +
$"maxHeight={maxScrollRectHeight:F0}, targetHeight={targetHeight:F0}");
}
// Force layout rebuild
RectTransform parentRect = _scrollRectTransform.parent as RectTransform;
if (parentRect != null) { LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect); }
}
private void Update() {
if (scrollRect == null) return;
bool hasOverflow = HasContentOverflow();
UpdateFadeGradient();
if (!hasOverflow) {
_isScrolling = false;
return;
}
// Hold Shift to pause scrolling (lets user read at their own pace)
bool isPausedByUser =
Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
if (isPausedByUser) return;
// Handle pause at bottom
if (_isPausedAtBottom) {
_pauseTimer += Time.deltaTime;
if (_pauseTimer >= pauseAtBottomSeconds) {
// Reset to top and start over
scrollRect.verticalNormalizedPosition = 1f;
_isPausedAtBottom = false;
_pauseTimer = 0f;
_visibleTime = 0f;
_isScrolling = false;
}
return;
}
// Track time visible
_visibleTime += Time.deltaTime;
// Start scrolling after delay
if (!_isScrolling && _visibleTime >= scrollDelaySeconds) { _isScrolling = true; }
// Perform scrolling (apply global speed multiplier from settings)
if (_isScrolling) {
float effectiveSpeed = scrollSpeed * GlobalScrollSpeedMultiplier;
float newPosition =
scrollRect.verticalNormalizedPosition - (effectiveSpeed * Time.deltaTime);
if (newPosition <= 0f) {
// Reached bottom
scrollRect.verticalNormalizedPosition = 0f;
_isScrolling = false;
_isPausedAtBottom = true;
_pauseTimer = 0f;
} else {
scrollRect.verticalNormalizedPosition = newPosition;
}
}
}
private bool HasContentOverflow() {
if (scrollRect == null || scrollRect.content == null) return false;
// Use TMP_Text.preferredHeight for accurate content measurement
float contentHeight;
var tmpText = scrollRect.content.GetComponentInChildren<TMP_Text>();
if (tmpText != null) {
contentHeight = tmpText.preferredHeight;
} else {
contentHeight = scrollRect.content.rect.height;
}
float viewportHeight = scrollRect.viewport != null
? scrollRect.viewport.rect.height
: scrollRect.GetComponent<RectTransform>().rect.height;
return contentHeight > viewportHeight + 1f; // Small buffer for floating point
}
private void UpdateFadeGradient() {
if (fadeGradient == null) return;
bool hasOverflow = HasContentOverflow();
bool notAtBottom = scrollRect != null && scrollRect.verticalNormalizedPosition > 0.01f;
// Show gradient when there's overflow and we're not at the bottom
fadeGradient.SetActive(hasOverflow && notAtBottom);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ac5d868c54ceb4893b1c95894cd4ec6f
@@ -54,43 +54,18 @@ namespace common.GUIUtils {
Type = type,
Time = DateTime.UtcNow
});
// Try to display immediately if possible, otherwise queue for later
try {
if (MainQueue.Q != null) {
MainQueue.Q.Enqueue(ShowErrorPanel);
} else {
_pendingShow = true;
}
} catch {
// MainQueue not ready yet - will show in Update
_pendingShow = true;
}
}
}
private bool _pendingShow = false;
private void ShowErrorPanel() {
if (errorTextField != null && panel != null) {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
}
}
// Register for log messages as early as possible
void Awake() { Application.logMessageReceivedThreaded += HandleLog; }
void Update() {
// Handle errors that occurred before MainQueue was ready
if (_pendingShow) {
_pendingShow = false;
ShowErrorPanel();
MainQueue.Q.Enqueue(() => {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
});
}
}
// Use this for initialization
void Start() { panel.gameObject.SetActive(false); }
void Start() {
panel.gameObject.SetActive(false);
Application.logMessageReceivedThreaded += HandleLog;
}
public void DismissClicked() {
lock (_messages) {
@@ -21,24 +21,14 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates - replaces polling via GetGameStatus
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
// Deprecated: Use SubscribeToGame for streaming updates instead
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
}
message GameSubscriptionRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message PostCommandRequest {
string game_id = 1;
int32 player_id = 2;
@@ -23,7 +23,6 @@ message IncompleteText {
string partial_text = 2;
.net.eagle0.eagle.internal.GeneratedTextRequest llm_request = 3;
int32 requested_after_history_count = 4;
int64 requested_at_millis = 5;
}
message UnrequestedText {
@@ -4,7 +4,6 @@ filegroup(
name = "settings",
srcs = ["settings.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
],
@@ -14,7 +13,6 @@ filegroup(
name = "game_parameters",
srcs = ["game_parameters.json"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test:__subpackages__",
],
@@ -24,7 +22,6 @@ filegroup(
name = "beasts",
srcs = ["beasts.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test:__subpackages__",
@@ -35,7 +32,6 @@ filegroup(
name = "headshots",
srcs = ["headshots.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator/image_paths:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator/random_hero_generator:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -50,7 +46,6 @@ filegroup(
"heroes.tsv",
],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test:__subpackages__",
@@ -61,7 +56,6 @@ filegroup(
name = "province_map",
srcs = ["province_map.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test:__subpackages__",
],
@@ -5,7 +5,6 @@ filegroup(
name = "settings",
srcs = ["settings.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/cpp/net/eagle0/shardok:__subpackages__",
"//src/test/cpp/net/eagle0/shardok:__subpackages__",
],
@@ -45,7 +44,6 @@ filegroup(
name = "battalion_types",
srcs = ["battalionTypes.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/cpp/net/eagle0/shardok:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -21,7 +21,6 @@ scala_binary(
":external_text_generation_caller",
":external_text_generation_service_impl",
":open_ai_chat_completions_service_impl",
":open_ai_responses_service_impl",
":streaming_text_results",
],
)
@@ -53,10 +52,7 @@ scala_library(
":external_text_generation_service_impl",
":rate_limits",
":streaming_text_results",
"//src/main/scala/net/eagle0/common/sse:okhttp_sse_listener",
"@maven//:com_squareup_okhttp3_okhttp",
"@maven//:com_squareup_okhttp3_okhttp_sse",
"@maven//:com_squareup_okio_okio_jvm",
"//src/main/scala/net/eagle0/common/sse:sse_subscriber",
],
)
@@ -90,24 +86,6 @@ scala_library(
],
)
scala_library(
name = "open_ai_responses_service_impl",
srcs = ["OpenAIResponsesServiceImpl.scala"],
visibility = [
"//visibility:public",
],
deps = [
":api_keys",
":external_text_generation_service_impl",
":open_ai_duration_parser",
":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 = "open_ai_duration_parser",
srcs = ["OpenAiDurationParser.scala"],
@@ -1,23 +1,21 @@
package net.eagle0.common.llm_integration
import java.io.IOException
import java.net.http.HttpRequest
import java.net.http.{HttpClient, HttpRequest, HttpResponse, HttpTimeoutException}
import java.net.http.HttpClient.{Redirect, Version}
import java.net.http.HttpResponse.ResponseInfo
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.time.{Duration, ZonedDateTime}
import java.time.temporal.ChronoUnit
import java.util.{Timer, TimerTask}
import java.util.concurrent.TimeUnit
import java.util.concurrent.CompletionException
import java.util.function.Consumer
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.jdk.CollectionConverters.{CollectionHasAsScala, IterableHasAsScala}
import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala}
import scala.jdk.FutureConverters.CompletionStageOps
import scala.util.{Failure, Success}
import net.eagle0.common.sse.OkHttpSseListener
import okhttp3.{MediaType, OkHttpClient, Request, RequestBody, Response}
import okhttp3.sse.{EventSource, EventSources}
import net.eagle0.common.sse.SseSubscriber
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, msg: String)
@@ -37,51 +35,10 @@ object ExternalTextGenerationCaller {
private def nextBackoff(backoffSeconds: Double): Double =
Math.min(backoffSeconds * backoffMultiplier, maxBackoffSeconds)
/** Convert a Java HttpRequest to an OkHttp Request */
private def toOkHttpRequest(javaRequest: HttpRequest): Request = {
val builder = new Request.Builder()
.url(javaRequest.uri().toString)
// Copy headers
javaRequest.headers().map().forEach { (name, values) =>
values.forEach(value => builder.addHeader(name, value))
}
// Handle request body
javaRequest.method() match {
case "GET" => builder.get()
case "POST" =>
val bodyPublisher = javaRequest.bodyPublisher().orElse(null)
if bodyPublisher != null then {
// Extract body content - for BodyPublishers.ofString, we can get the content
val bodyContent = new StringBuilder()
val subscriber = new java.util.concurrent.Flow.Subscriber[java.nio.ByteBuffer] {
override def onSubscribe(subscription: java.util.concurrent.Flow.Subscription): Unit =
subscription.request(Long.MaxValue)
override def onNext(item: java.nio.ByteBuffer): Unit =
bodyContent.append(java.nio.charset.StandardCharsets.UTF_8.decode(item).toString)
override def onError(throwable: Throwable): Unit = ()
override def onComplete(): Unit = ()
}
bodyPublisher.subscribe(subscriber)
val contentType =
javaRequest.headers().firstValue("Content-Type").orElse("application/json")
val mediaType = MediaType.parse(contentType)
builder.post(RequestBody.create(bodyContent.toString, mediaType))
} else {
builder.post(RequestBody.create("", null))
}
case other => throw new UnsupportedOperationException(s"HTTP method $other not supported")
}
builder.build()
}
}
final class ExternalTextGenerationCaller(
val timeoutSeconds: Int = 10,
val readTimeoutSeconds: Int = 60,
serviceImpl: ExternalTextGenerationServiceImpl
) {
private var inProgressCount = 0
@@ -100,17 +57,13 @@ final class ExternalTextGenerationCaller(
implicit val ec: ExecutionContext = ExecutionContext.global
// OkHttp client with read timeout - the key benefit over Java HttpClient
// If no data is received for readTimeoutSeconds, the connection will timeout
private val okHttpClient = new OkHttpClient.Builder()
.connectTimeout(timeoutSeconds.toLong, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds.toLong, TimeUnit.SECONDS)
.writeTimeout(timeoutSeconds.toLong, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
private val httpClient = HttpClient
.newBuilder()
.version(Version.HTTP_2)
.followRedirects(Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(timeoutSeconds))
.build()
private val eventSourceFactory = EventSources.createFactory(okHttpClient)
private var successDurations =
new scala.collection.mutable.ArrayBuffer[Long]()
@@ -119,7 +72,7 @@ final class ExternalTextGenerationCaller(
partialCompletion: Option[String],
streamingConsumer: Consumer[StreamingTextResults],
backoffSeconds: Double = ExternalTextGenerationCaller.initialBackoffSeconds
): Future[Unit] =
): Future[HttpResponse[Unit]] =
streamCompletion(
inputText = inputText,
request = serviceImpl.makeRequest(
@@ -135,13 +88,13 @@ final class ExternalTextGenerationCaller(
request: HttpRequest,
backoffSeconds: Double,
streamingConsumer: Consumer[StreamingTextResults]
): Future[Unit] = {
): Future[HttpResponse[Unit]] = {
val startTime = System.currentTimeMillis()
def tryAgain: () => Future[Unit] = () => {
def tryAgain: () => Future[HttpResponse[Unit]] = () => {
println(s"Trying again after $backoffSeconds seconds...")
val promise = Promise[Unit]()
val promise = Promise[HttpResponse[Unit]]()
val t = new Timer()
t.schedule(
new TimerTask {
@@ -160,63 +113,104 @@ final class ExternalTextGenerationCaller(
promise.future
}
val okHttpRequest = ExternalTextGenerationCaller.toOkHttpRequest(request)
val sseListener = new OkHttpSseListener(serviceImpl.stringConsumer(streamingConsumer))
inProgressCount += 1
// Start the SSE connection
val eventSource = eventSourceFactory.newEventSource(okHttpRequest, sseListener)
// Convert the CompletableFuture to a Scala Future
sseListener.getFuture.asScala.andThen {
case _ =>
inProgressCount -= 1
}.transform {
case Success(_) =>
val duration = System.currentTimeMillis() - startTime
this.synchronized {
successDurations.addOne(duration)
if successDurations.size % 100 == 0 then {
successDurations = successDurations.sorted
val p99pos =
successDurations.size - successDurations.size / 100
println(s"p99 duration: ${successDurations(p99pos)}ms")
}
}
Success(())
case Failure(e: SocketTimeoutException) =>
println(s"Read timeout after ${readTimeoutSeconds}s: ${e.getMessage}")
Failure(
ExternalTextGenerationError.Timeout(
s"Read timeout after ${readTimeoutSeconds}s: ${e.getMessage}"
httpClient
.sendAsync(
request,
(respInfo: ResponseInfo) =>
if respInfo.statusCode() == HttpURLConnection.HTTP_OK then
new SseSubscriber(serviceImpl.stringConsumer(streamingConsumer))
else
// For error responses, read the body to get error details
HttpResponse.BodySubscribers.mapping(
HttpResponse.BodySubscribers.ofString(java.nio.charset.StandardCharsets.UTF_8),
(body: String) => {
val code = respInfo.statusCode()
println(s"Error response ($code): $body")
// 4xx errors (except 429) are client errors - don't retry
if code >= 400 && code < 500 && code != 429 then
throw ExternalTextGenerationError.Http(
code,
s"Client error $code: $body"
)
else
throw new RuntimeException(
s"Server error $code: $body"
)
}
)
)
.asScala
.andThen {
case x =>
inProgressCount -= 1
x
}
.transform {
case Success(httpResponse) =>
val rateLimits = serviceImpl.rateLimitsFrom(
headers = httpResponse
.headers()
.map()
.asScala
.map { case (k, v) => (k, v.asScala.toVector) }
.toMap
)
)
currentRateLimits = rateLimits
currentRateLimitTime = ZonedDateTime.now()
case Failure(e: IOException) if e.getMessage != null && e.getMessage.contains("timeout") =>
println(s"Timeout: ${e.getMessage}")
Failure(
ExternalTextGenerationError.Timeout(
s"Timeout: ${e.getMessage}"
val responseCode = httpResponse.statusCode()
if responseCode < HttpURLConnection.HTTP_BAD_REQUEST then {
val duration = System.currentTimeMillis() - startTime
this.synchronized {
successDurations.addOne(duration)
if successDurations.size % 100 == 0 then {
successDurations = successDurations.sorted
val p99pos =
successDurations.size - successDurations.size / 100
println(s"p99 duration: ${successDurations(p99pos)}ms")
}
Success(httpResponse)
}
} else if responseCode == 429 then
Failure(
ExternalTextGenerationError.RateLimit(
responseCode,
httpResponse.toString
)
)
else
Failure(
ExternalTextGenerationError.Http(
responseCode,
"An error occurred while generating a response.\n" + httpResponse
)
)
case Failure(timeoutException: HttpTimeoutException) =>
Failure(
ExternalTextGenerationError.Timeout(
s"Timed out: ${timeoutException.getMessage}"
)
)
)
case Failure(exception) => Failure(exception)
}.recoverWith {
// Don't retry 4xx client errors - they won't succeed
case e: ExternalTextGenerationError.Http =>
Future.failed(e)
// Retry transient errors (timeouts, network issues)
case e: ExternalTextGenerationError.Timeout =>
println(s"Timeout error - retrying: ${e.message}")
tryAgain()
case e: IOException =>
println(s"IOException - retrying: ${e.getMessage}")
tryAgain()
case e: Throwable =>
println(s"error $e - retrying")
tryAgain()
}
case Failure(exception) => Failure(exception)
}
.recoverWith {
// Don't retry 4xx client errors - they won't succeed
case e: CompletionException if e.getCause.isInstanceOf[ExternalTextGenerationError.Http] =>
Future.failed(e.getCause)
case e: ExternalTextGenerationError.Http =>
Future.failed(e)
// Retry transient errors (5xx, timeouts, network issues)
case e: CompletionException =>
println(s"CompletionException error $e - retrying")
tryAgain()
case e: Throwable =>
println(s"error $e - retrying")
tryAgain()
}
}
}
@@ -4,9 +4,6 @@ import scala.concurrent.duration.{Duration, SECONDS}
import scala.concurrent.Await
//import scala.util.Random
enum LlmProvider:
case Claude, OpenAIChatCompletions, OpenAIResponses
object ExternalTextGenerationCallerApp {
def main(args: Array[String]): Unit = {
// val personalityWords = Vector(
@@ -33,16 +30,8 @@ object ExternalTextGenerationCallerApp {
val chatGptImpl: ExternalTextGenerationServiceImpl =
new OpenAIChatCompletionsServiceImpl()
val openAiResponsesImpl: ExternalTextGenerationServiceImpl =
new OpenAIResponsesServiceImpl()
val selectedProvider = LlmProvider.OpenAIResponses
val caller = new ExternalTextGenerationCaller(
serviceImpl = selectedProvider match {
case LlmProvider.Claude => claudeImpl
case LlmProvider.OpenAIChatCompletions => chatGptImpl
case LlmProvider.OpenAIResponses => openAiResponsesImpl
}
val caller = new ExternalTextGenerationCaller(
serviceImpl = if true then claudeImpl else chatGptImpl
)
(0 to 0).foreach { _ =>
@@ -1,200 +0,0 @@
package net.eagle0.common.llm_integration
import java.net.{URI, URL}
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.time.{Duration, ZonedDateTime}
import java.util.function.Consumer
import org.json4s.{DefaultFormats, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.{Json, Serialization}
/**
* OpenAI Responses API implementation.
*
* The Responses API is OpenAI's newer API primitive that offers: - Better performance with reasoning models (3%
* improvement in SWE-bench) - Lower costs through improved cache utilization (40-80% improvement) - Semantic streaming
* events with clear lifecycle (response.created, response.output_text.delta, response.completed) - Built-in tools (web
* search, file search, computer use, code interpreter)
*
* @see
* https://platform.openai.com/docs/api-reference/responses
*/
object OpenAIResponsesServiceImpl {
val gpt5: String = "gpt-5.1"
private val apiKey = ApiKeys.openAI
private val baseURL = new URL("https://api.openai.com/v1/responses")
private val temperature: Double = 1.0
private def baseRequest(timeoutSeconds: Int): HttpRequest.Builder =
HttpRequest
.newBuilder()
.uri(URI.create(baseURL.toString))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header(
"Authorization",
s"Bearer ${OpenAIResponsesServiceImpl.apiKey}"
)
}
class OpenAIResponsesServiceImpl(
timeoutSeconds: Int = 10,
val defaultModelName: String = "gpt-5.1"
) extends ExternalTextGenerationServiceImpl {
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
private def requestDictionary(
inputText: String,
partialCompletion: Option[String]
): Map[String, Any] = {
// For partial completions, we need to format as an array of input items
// The Responses API uses "input" instead of "messages"
val input = partialCompletion match {
case Some(partial) =>
// When we have a partial completion, send as array of items
Vector(
Map("type" -> "message", "role" -> "user", "content" -> inputText),
Map("type" -> "message", "role" -> "assistant", "content" -> partial)
)
case None =>
// Simple text input when no partial completion
inputText
}
Map(
"model" -> defaultModelName,
"temperature" -> OpenAIResponsesServiceImpl.temperature,
"stream" -> true,
"input" -> input
)
}
override def makeRequest(
inputText: String,
partialCompletion: Option[String]
): HttpRequest =
OpenAIResponsesServiceImpl
.baseRequest(timeoutSeconds = timeoutSeconds)
.header("Content-Type", "application/json")
.POST(
BodyPublishers.ofString(
Serialization.write(
requestDictionary(
inputText = inputText,
partialCompletion = partialCompletion
)
)
)
)
.build()
override def stringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
): Consumer[String] = (t: String) => {
val json = new Json(DefaultFormats)
val parsedJson =
try
json.parse(t)
catch {
case pe: org.json4s.ParserUtil.ParseException =>
println(s"Failed to parse JSON: $t")
throw pe
}
val eventType = (parsedJson \ "type").extractOpt[String]
eventType match {
case Some("response.output_text.delta") =>
// Text delta event - extract the delta and stream ID
val delta = (parsedJson \ "delta").extract[String]
val itemId = (parsedJson \ "item_id").extractOpt[String].getOrElse("unknown")
val streamId = itemId
streamingConsumer.accept(
StreamingTextResults(
streamId = streamId,
value = delta,
completed = false
)
)
case Some("response.output_text.done") =>
// Text is complete for this output item
val itemId = (parsedJson \ "item_id").extractOpt[String].getOrElse("unknown")
val streamId = itemId
streamingConsumer.accept(
StreamingTextResults(
streamId = streamId,
value = "",
completed = true
)
)
case Some("response.completed") =>
// Response is fully complete - extract the response ID as streamId
val responseId = (parsedJson \ "response" \ "id").extractOpt[String].getOrElse("unknown")
streamingConsumer.accept(
StreamingTextResults(
streamId = responseId,
value = "",
completed = true
)
)
case Some("response.created") | Some("response.in_progress") | Some("response.output_item.added") | Some(
"response.content_part.added"
) =>
// Lifecycle events - ignore, no content to stream
()
case Some("error") =>
// Error event
val errorMsg = (parsedJson \ "error" \ "message").extractOpt[String].getOrElse("Unknown error")
throw new RuntimeException(s"OpenAI Responses API error: $errorMsg")
case Some(other) =>
// Unknown event type - log but continue
println(s"OpenAI Responses API: ignoring unknown event type: $other")
case None =>
// No event type - might be malformed, log it
println(s"OpenAI Responses API: received message without event type: $t")
}
}
override def rateLimitsFrom(
headers: Map[String, Vector[String]]
): Option[RateLimits] =
// The Responses API uses the same rate limit headers as Chat Completions
for {
requestLimit <- headers.get("x-ratelimit-limit-requests")
tokenLimit <- headers.get("x-ratelimit-limit-tokens")
requestsRemaining <- headers.get("x-ratelimit-remaining-requests")
tokensRemaining <- headers.get("x-ratelimit-remaining-tokens")
requestResetTime <- headers.get("x-ratelimit-reset-requests")
tokenResetTime <- headers.get("x-ratelimit-reset-tokens")
} yield RateLimits(
requestLimit = requestLimit.head.toInt,
tokenLimit = tokenLimit.head.toInt,
requestsRemaining = requestsRemaining.head.toInt,
tokensRemaining = tokensRemaining.head.toInt,
requestResetTime = ZonedDateTime
.now()
.plus(
OpenAiDurationParser
.parseDuration(requestResetTime.head)
.getOrElse(Duration.ZERO)
),
tokenResetTime = ZonedDateTime
.now()
.plus(
OpenAiDurationParser
.parseDuration(tokenResetTime.head)
.getOrElse(Duration.ZERO)
)
)
}
@@ -18,13 +18,3 @@ scala_library(
"@maven//:org_json4s_json4s_core_3",
],
)
scala_library(
name = "okhttp_sse_listener",
srcs = ["OkHttpSseListener.scala"],
visibility = ["//visibility:public"],
deps = [
"@maven//:com_squareup_okhttp3_okhttp",
"@maven//:com_squareup_okhttp3_okhttp_sse",
],
)
@@ -1,62 +0,0 @@
package net.eagle0.common.sse
import java.util.concurrent.CompletableFuture
import java.util.function.Consumer
import okhttp3.sse.{EventSource, EventSourceListener}
import okhttp3.Response
/**
* OkHttp-based SSE listener that wraps our existing SseEventReader logic.
*
* Key benefit over Java HttpClient: OkHttp supports read timeouts, so if the server stops sending data without properly
* closing the connection, we'll get a timeout error instead of waiting forever.
*/
class OkHttpSseListener(messageDataConsumer: Consumer[String]) extends EventSourceListener {
private val future: CompletableFuture[Unit] = new CompletableFuture[Unit]
private val DoneToken = "[DONE]"
def getFuture: CompletableFuture[Unit] = future
override def onOpen(eventSource: EventSource, response: Response): Unit = {
// Connection established, nothing to do
}
override def onEvent(
eventSource: EventSource,
id: String,
`type`: String,
data: String
): Unit =
try
if data != DoneToken then {
messageDataConsumer.accept(data)
}
// If it's [DONE], just ignore - onClosed will be called
catch {
case e: Exception =>
val _ = future.completeExceptionally(e)
eventSource.cancel()
}
override def onClosed(eventSource: EventSource): Unit =
if !future.isDone then {
val _ = future.complete(())
}
override def onFailure(
eventSource: EventSource,
t: Throwable,
response: Response
): Unit =
if !future.isDone then {
if response != null then {
println(s"SSE failure with response code ${response.code()}: ${t.getMessage}")
} else {
println(s"SSE failure: ${t.getMessage}")
}
val _ = future.completeExceptionally(t)
}
}
@@ -21,8 +21,7 @@ case class IncompleteClientText(
id: ClientTextId,
partialText: String,
requestedAfterHistoryCount: Int,
llmRequest: GeneratedTextRequest,
requestedAtMillis: Long
llmRequest: GeneratedTextRequest
) extends ClientText {
def append(newText: String): IncompleteClientText =
copy(partialText = partialText + newText)
@@ -17,15 +17,6 @@ trait ClientTextStore {
def unrequestedTexts: Map[ClientTextId, UnrequestedClientText]
def accessibleTo: Map[ClientTextId, Vector[FactionId]]
/** Returns incomplete texts that have been waiting longer than the threshold */
def stalledIncompleteTexts(
thresholdMillis: Long,
currentTimeMillis: Long = System.currentTimeMillis()
): Vector[IncompleteClientText] =
incompleteTexts.values
.filter(ict => currentTimeMillis - ict.requestedAtMillis > thresholdMillis)
.toVector
def saved: ClientTextStore
def withAddedTextRequest(
@@ -69,8 +69,7 @@ case class ClientTextStoreImpl(
id = id,
partialText = "",
llmRequest = unrequested.llmRequest,
requestedAfterHistoryCount = unrequested.requestedAfterHistoryCount,
requestedAtMillis = System.currentTimeMillis()
requestedAfterHistoryCount = unrequested.requestedAfterHistoryCount
)),
unrequestedTexts = unrequestedTexts - id,
incompleteTextsAreSaved = false
@@ -239,8 +238,7 @@ object ClientTextStoreImpl {
id = ict.id,
partialText = ict.text,
llmRequest = Some(ict.llmRequest),
requestedAfterHistoryCount = ict.requestedAfterHistoryCount,
requestedAtMillis = ict.requestedAtMillis
requestedAfterHistoryCount = ict.requestedAfterHistoryCount
)
}.toVector,
unrequestedTexts = completeSaved.unrequestedTexts.map {
@@ -332,12 +330,7 @@ object ClientTextStoreImpl {
id = it.id,
partialText = it.partialText,
llmRequest = it.llmRequest.get,
requestedAfterHistoryCount = it.requestedAfterHistoryCount,
// Use persisted timestamp if available, otherwise use current time
// (for backwards compatibility with old persisted data)
requestedAtMillis =
if it.requestedAtMillis > 0 then it.requestedAtMillis
else System.currentTimeMillis()
requestedAfterHistoryCount = it.requestedAfterHistoryCount
)
}.toVector,
icts.unrequestedTexts.map { it =>
@@ -48,9 +48,7 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/availability",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:check_for_fulfilled_quests_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:hero_backstory_update_action_generator",
@@ -59,19 +57,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_proto_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:runtime_validator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_runtime_validator",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
@@ -152,16 +149,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_scala_proto",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_scala_proto",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -211,9 +203,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:deterministic_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:vigor_xp_applier",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_runtime_validator",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -224,8 +218,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
@@ -7,12 +7,7 @@ import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.library.actions.applier.{
ActionResultApplierImpl,
ActionResultProtoApplier,
ActionResultProtoApplierImpl,
ActionResultWithResultingState
}
import net.eagle0.eagle.library.actions.applier.{ActionResultProtoApplier, ActionResultProtoApplierImpl}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
import net.eagle0.eagle.library.actions.impl.action.{
CheckForFulfilledQuestsAction,
@@ -20,12 +15,11 @@ import net.eagle0.eagle.library.actions.impl.action.{
ResolveBattleAction
}
import net.eagle0.eagle.library.actions.impl.command.{AvailableCommandTypeMap, CommandFactory}
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.impl.common.{ActionWithResultingState, RandomStateProtoSequencer}
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.validations.{RuntimeValidator, ScalaRuntimeValidator}
import net.eagle0.eagle.library.util.validations.RuntimeValidator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EngineImpl.{appliedResults, appliedResultsScala, withUpdateChecks}
import net.eagle0.eagle.library.EngineImpl.{appliedResults, withUpdateChecks}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
@@ -49,10 +43,10 @@ object EngineImpl {
private def withPhaseAdvancement(
engineAndResultsImpl: EngineAndResultsImpl
): EngineAndResultsImpl =
engineAndResultsImpl.recursiveTransformScala(eng =>
engineAndResultsImpl.recursiveTransform(eng =>
RoundPhaseAdvancer.checkForPhaseAdvancement(
currentState = eng.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
currentState = GameStateConverter.toProto(eng.currentState),
actionResultProtoApplier = eng.actionResultProtoApplier,
history = eng.history,
availableCommandsFactory = eng.availableCommandsFactory,
heroGenerator = eng.heroGenerator,
@@ -103,31 +97,14 @@ object EngineImpl {
results = results.map(_.actionResult)
)
)
def appliedResultsScala(
engine: EngineImpl,
results: Vector[ActionResultWithResultingState]
): EngineAndResults = withUpdateChecks(
EngineAndResultsImpl(
engine = engine.copy(
currentState = results.lastOption
.map(_.resultingState)
.getOrElse(engine.currentState),
history = engine.history.withNewResultsScala(results)
),
results = results.map(awrs =>
net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter.toProto(awrs.actionResult)
)
)
)
}
final case class EngineAndResultsImpl(
engine: EngineImpl,
results: Vector[ActionResult]
) extends EngineAndResults {
def recursiveTransformScala(
f: EngineImpl => Vector[ActionResultWithResultingState]
def recursiveTransform(
f: EngineImpl => Vector[ActionWithResultingState]
): EngineAndResultsImpl = {
@tailrec
def go(
@@ -138,7 +115,7 @@ final case class EngineAndResultsImpl(
if goResults.isEmpty then EngineAndResultsImpl(eng, acc)
else
appliedResultsScala(eng, goResults) match {
appliedResults(eng, goResults) match {
case EngineAndResultsImpl(eng2, res) =>
go(eng2, acc ++ res)
}
@@ -149,13 +126,13 @@ final case class EngineAndResultsImpl(
def recursiveTransformT(
f: EngineImpl => Vector[ActionResultT]
): EngineAndResultsImpl = recursiveTransformScala { eng =>
val actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator))
RandomStateSequencer(
): EngineAndResultsImpl = recursiveTransform { eng =>
val results = f(eng)
RandomStateProtoSequencer(
initialState = eng.currentState,
actionResultApplier = actionResultApplier,
actionResultProtoApplier = eng.actionResultProtoApplier,
functionalRandom = SeededRandom(eng.currentState.randomSeed)
).withActionResults(_ => f(eng)).resultsWithStates.newValue
).withActionResultTs(_ => results).results.newValue
}
def saveNow: EngineAndResultsImpl =
@@ -305,34 +282,37 @@ case class EngineImpl(
)
val availableCommand = availableCommandOpt.get
val sequencer = RandomStateSequencer(
val sequencer = RandomStateProtoSequencer(
initialState = this.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
actionResultProtoApplier = actionResultProtoApplier,
functionalRandom = SeededRandom(this.currentState.randomSeed)
).withTCommand { gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
)
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
).withActionResults { gs =>
val results = commandFactory
.makeCommand(
actingFactionId = factionId,
gameState = GameStateConverter.fromProto(gs),
availableCommand = availableCommand,
selectedCommand = selectedCommand
)
.execute(actionResultProtoApplier)
.map(_.actionResult)
// Validate that the first result has an acting faction set (required for player commands)
val firstResult = sequencer.actionResults.newValue.headOption
if !firstResult.forall(_.actingFactionId.isDefined) then {
print(
"Result with type " + firstResult.map(_.actionResultType).getOrElse("unknown") + " did not have a player set"
if !results.headOption.forall(_.player.isDefined) then {
print(
"Result with type " + results.head.`type` + " did not have a player set"
)
}
internalRequire(
results.headOption.forall(_.player.isDefined),
s"Result with type ${results.head.`type`} did not have a player set"
)
}
internalRequire(
firstResult.forall(_.actingFactionId.isDefined),
s"Result with type ${firstResult.map(_.actionResultType).getOrElse("unknown")} did not have a player set"
)
appliedResultsScala(
results
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator.fromGameState(gs))
appliedResults(
engine = this,
results = sequencer.resultsWithStates.newValue
results = sequencer.results.newValue
)
}
}
@@ -1,10 +1,7 @@
package net.eagle0.eagle.library
import net.eagle0.eagle.{FactionId, RoundId, ShardokGameId}
import net.eagle0.eagle.library.actions.applier.ActionResultWithResultingState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.shardok.api.action_result_view.ActionResultView as ShardokActionResultView
@@ -40,14 +37,6 @@ trait GameHistory {
def withNewResults(newResults: Vector[ActionWithResultingState]): GameHistory
def withNewResultsScala(newResults: Vector[ActionResultWithResultingState]): GameHistory =
withNewResults(newResults.map { awrs =>
ActionWithResultingState(
actionResult = ActionResultProtoConverter.toProto(awrs.actionResult),
gameState = GameStateConverter.toProto(awrs.resultingState)
)
})
def shardokCount(shardokGameId: ShardokGameId): Int
def shardokGameState(
@@ -3,30 +3,38 @@ package net.eagle0.eagle.library
import scala.collection.mutable
import net.eagle0.common.SeededRandom
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.{ActionResultApplier, ActionResultWithResultingState}
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.common.round_phase.RoundPhase.*
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.applier.{ActionResultProtoApplier, ActionResultTApplierImpl}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
import net.eagle0.eagle.library.actions.impl.action.*
import net.eagle0.eagle.library.actions.impl.command.CommandFactory
import net.eagle0.eagle.library.actions.impl.common.VigorXPApplier
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.validations.ScalaRuntimeValidator
import net.eagle0.eagle.library.util.EagleRequire.internalValidated
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.model.state.RoundPhase.*
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.RoundId
object RoundPhaseAdvancer {
private val times: mutable.Map[RoundPhase, Long] =
mutable.Map(RoundPhase.allValues.map(_ -> 0L)*)
mutable.Map(RoundPhase.values.map(_ -> 0L)*)
private val print = false
private val roundsBetweenPrint = 100
private def printTimings(roundId: RoundId): Unit = {
val totalTime = times.values.sum.toDouble / 1000.0
times.toVector.sortBy(_._2)(using Ordering.Long.reverse).foreach {
times.toVector.sortBy(_._2)(Ordering.Long.reverse).foreach {
case (phase, time) =>
val timeInSecs = time.toDouble / 1000.0
val msPerRound = time.toDouble / roundId.toDouble
@@ -40,338 +48,332 @@ object RoundPhaseAdvancer {
def checkForPhaseAdvancement(
currentState: GameState,
actionResultApplier: ActionResultApplier,
actionResultProtoApplier: ActionResultProtoApplier,
history: GameHistory,
availableCommandsFactory: AvailableCommandsFactory,
heroGenerator: HeroGenerator,
commandFactory: CommandFactory
): Vector[ActionResultWithResultingState] = {
// Lazy conversion to proto for AvailableCommandsFactory calls
lazy val currentStateProto: GameStateProto = GameStateConverter.toProto(currentState)
): Vector[ActionWithResultingState] = {
val currentPhase = currentState.currentPhase
val startTime = System.currentTimeMillis
if print && currentPhase == NewRound && currentState.currentRoundId % roundsBetweenPrint == 0
if print && currentPhase == NEW_ROUND && currentState.currentRoundId % roundsBetweenPrint == 0
then {
printTimings(currentState.currentRoundId)
}
val results: Vector[ActionResultWithResultingState] = currentPhase match {
case NewRound =>
val actionResults = NewRoundAction(currentState, history, actionResultApplier)
.randomResults(SeededRandom(currentState.randomSeed))
.newValue
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, actionResults)
case PrisonerExchange =>
actionResultApplier.applyActionResults(
currentState,
PrisonerExchangeAction(currentState).results
val results: Vector[ActionWithResultingState] = currentPhase match {
case UNKNOWN_PHASE =>
throw new IllegalStateException(
"Somehow we're in game state UNKNOWN_PHASE"
)
case ProvinceEvents =>
actionResultApplier.applyActionResults(
case NEW_ROUND =>
NewRoundAction(GameStateConverter.fromProto(currentState), history).execute(actionResultProtoApplier)
case PRISONER_EXCHANGE =>
actionResultProtoApplier.applyActionResults(
currentState,
PerformProvinceEventsAction(currentState)
.results(SeededRandom(currentState.randomSeed))
PrisonerExchangeAction(GameStateConverter.fromProto(currentState)).results
.map(ActionResultProtoConverter.toProto)
)
case ForcedTurnBack =>
actionResultApplier.applyActionResults(
case PROVINCE_EVENTS =>
PerformProvinceEventsAction(
GameStateConverter.fromProto(currentState)
).execute(currentState, actionResultProtoApplier)
case FORCED_TURN_BACK =>
actionResultProtoApplier.applyActionResults(
currentState,
PerformForcedTurnBackAction(currentState).results
PerformForcedTurnBackAction(GameStateConverter.fromProto(currentState)).results
.map(ActionResultProtoConverter.toProto)
)
case ProvinceMoveResolution =>
val actionResults = PerformProvinceMoveResolutionAction(currentState, actionResultApplier)
.results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, actionResults)
case PROVINCE_MOVE_RESOLUTION =>
PerformProvinceMoveResolutionAction(
GameStateConverter.fromProto(currentState)
).execute(actionResultProtoApplier)
case HandleRiot =>
case HANDLE_RIOT =>
if availableCommandsFactory
.hasAvailableHandleRiotPhaseCommands(currentStateProto)
.hasAvailableHandleRiotPhaseCommands(currentState)
then Vector.empty
else
val actionResults = EndHandleRiotsPhaseAction(
gameState = currentState,
EndHandleRiotsPhaseAction(
gameState = GameStateConverter.fromProto(currentState),
commandsForProvince = pid =>
availableCommandsFactory
.handleRiotPhaseCommandsForOneProvince(
currentStateProto,
currentStateProto.provinces(pid)
currentState,
currentState.provinces(pid)
),
commandFactory = commandFactory,
actionResultApplier = actionResultApplier
).results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, actionResults)
applier = ActionResultTApplierImpl(ScalaRuntimeValidator)
).execute(currentState, actionResultProtoApplier)
case HeroDepartures =>
val actionResults = PerformHeroDeparturesAction(gameState = currentState)
.results(SeededRandom(currentState.randomSeed))
actionResultApplier.applyActionResults(currentState, actionResults)
case HERO_DEPARTURES =>
PerformHeroDeparturesAction(
gameState = GameStateConverter.fromProto(currentState)
).execute(currentState, actionResultProtoApplier)
case UnaffiliatedHeroActions =>
val actionResults = PerformUnaffiliatedHeroesAction(
gameState = currentState,
heroGenerator = heroGenerator,
actionResultApplier = actionResultApplier
).results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, actionResults)
case UNAFFILIATED_HERO_ACTIONS =>
PerformUnaffiliatedHeroesAction(
gameState = GameStateConverter.fromProto(currentState),
heroGenerator = heroGenerator
).execute(actionResultProtoApplier)
case PleaseRecruitMe =>
case PLEASE_RECRUIT_ME =>
if availableCommandsFactory
.hasAvailablePleaseRecruitMePhaseCommands(currentStateProto)
.hasAvailablePleaseRecruitMePhaseCommands(currentState)
then Vector.empty
else
Vector(
actionResultApplier.applyActionResult(
actionResultProtoApplier.applyActionResult(
currentState,
EndPleaseRecruitMePhaseAction(currentState).immediateExecute
ActionResultProtoConverter.toProto(
EndPleaseRecruitMePhaseAction(GameStateConverter.fromProto(currentState)).immediateExecute
)
)
)
case VassalCommands =>
case VASSAL_COMMANDS =>
val vassalCommandResults = PerformVassalCommandsPhaseAction(
gameState = currentState,
gameState = GameStateConverter.fromProto(currentState),
commandsForProvince = availableCommandsFactory
.commandPhaseCommandsForProvince(currentStateProto, _),
.commandPhaseCommandsForProvince(currentState, _),
commandFactory = commandFactory,
actionResultApplier = actionResultApplier
).results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
val appliedResults = actionResultApplier.applyActionResults(currentState, vassalCommandResults)
applier = ActionResultTApplierImpl(ScalaRuntimeValidator)
).execute(currentState, actionResultProtoApplier)
if appliedResults.nonEmpty then appliedResults
if vassalCommandResults.nonEmpty then vassalCommandResults
else
val endResults = EndVassalCommandsPhaseAction(currentState, actionResultApplier)
.results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, endResults)
EndVassalCommandsPhaseAction(GameStateConverter.fromProto(currentState)).execute(
actionResultProtoApplier
)
case PlayerCommands =>
case PLAYER_COMMANDS =>
if availableCommandsFactory
.hasAvailablePlayerCommandsPhaseCommands(
currentStateProto
currentState
)
then Vector.empty
else
actionResultApplier.applyActionResults(
actionResultProtoApplier.applyActionResults(
currentState,
EndPlayerCommandsPhaseAction(
currentState,
actionResultApplier
).randomResults(functionalRandom = SeededRandom(currentState.randomSeed)).newValue
GameStateConverter.fromProto(currentState),
ActionResultTApplierImpl(ScalaRuntimeValidator)
).randomResults(functionalRandom = SeededRandom(currentState.randomSeed))
.newValue
.map(
ActionResultProtoConverter.toProto(_)
)
)
case HostileArmySetup =>
case HOSTILE_ARMY_SETUP =>
Vector(
actionResultApplier.applyActionResult(
actionResultProtoApplier.applyActionResult(
currentState,
PerformHostileArmySetupAction(currentState).immediateExecute
ActionResultProtoConverter.toProto(
PerformHostileArmySetupAction(
GameStateConverter.fromProto(currentState)
).immediateExecute
)
)
)
case FreeForAllDecision =>
case FREE_FOR_ALL_DECISION =>
if availableCommandsFactory
.hasAvailableFreeForAllDecisionPhaseCommands(
currentStateProto
currentState
)
then Vector.empty
else
// There may eventually be VassalAttackDecisions, but for now we're leaving that on the player
actionResultApplier.applyActionResults(
actionResultProtoApplier.applyActionResults(
currentState,
EndFreeForAllDecisionPhaseAction(currentState).results
EndFreeForAllDecisionPhaseAction(GameStateConverter.fromProto(currentState)).results.map(
ActionResultProtoConverter.toProto(_)
)
)
case FreeForAllBattleRequest =>
val requestResults = actionResultApplier.applyActionResults(
case FREE_FOR_ALL_BATTLE_REQUEST =>
val requestResults = actionResultProtoApplier.applyActionResults(
currentState,
RequestFreeForAllBattlesAction(currentState).results
RequestFreeForAllBattlesAction(
GameStateConverter.fromProto(currentState)
).results.map(ActionResultProtoConverter.toProto)
)
val latestState = requestResults.lastOption.map(_.resultingState).getOrElse(currentState)
requestResults :+ actionResultApplier.applyActionResult(
val latestState = requestResults.lastOption.map(_.gameState).getOrElse(currentState)
requestResults :+ actionResultProtoApplier.applyActionResult(
latestState,
EndFreeForAllBattleRequestPhaseAction.immediateExecute
ActionResultProtoConverter.toProto(EndFreeForAllBattleRequestPhaseAction.immediateExecute)
)
case FreeForAllBattleResolution =>
case FREE_FOR_ALL_BATTLE_RESOLUTION =>
if currentState.outstandingBattles.isEmpty then
Vector(
actionResultApplier.applyActionResult(
actionResultProtoApplier.applyActionResult(
currentState,
EndFreeForAllBattleResolutionPhaseAction.immediateExecute
ActionResultProtoConverter.toProto(EndFreeForAllBattleResolutionPhaseAction.immediateExecute)
)
)
else Vector.empty // wait for battles to resolve
case UncontestedConquest =>
actionResultApplier.applyActionResults(
case UNCONTESTED_CONQUEST =>
actionResultProtoApplier.applyActionResults(
currentState,
PerformUncontestedConquestAction(
gameId = currentState.gameId,
currentRoundId = currentState.currentRoundId,
currentDate = currentState.currentDate.get,
provinces = currentState.provinces,
factions = currentState.factions,
heroes = currentState.heroes,
battalions = currentState.battalions
GameStateConverter.fromProto(currentState)
).results
.map(ActionResultProtoConverter.toProto)
)
case AttackDecision =>
case ATTACK_DECISION =>
if availableCommandsFactory.hasAvailableAttackDecisionPhaseCommands(
currentStateProto
currentState
)
then Vector.empty
else {
// There may eventually be VassalAttackDecisions, but for now we're leaving that on the player
actionResultApplier.applyActionResults(
actionResultProtoApplier.applyActionResults(
currentState,
EndAttackDecisionPhaseAction(
gameId = currentState.gameId,
currentRoundId = currentState.currentRoundId,
currentDate = currentState.currentDate.get,
provinces = currentState.provinces.values.toVector
).results
GameStateConverter.fromProto(currentState)
).results.map(ActionResultProtoConverter.toProto)
)
}
case DefenseDecision =>
case DEFENSE_DECISION =>
if availableCommandsFactory.hasAvailablePlayerDefenseCommands(
currentStateProto
currentState
)
then Vector.empty
else {
val vassalCommandResults = PerformVassalDefenseDecisionsAction(
gameState = currentState,
gameState = GameStateConverter.fromProto(currentState),
commandsForProvince = availableCommandsFactory
.defensePhaseCommandsForProvince(currentStateProto, _),
.defensePhaseCommandsForProvince(currentState, _),
commandFactory = commandFactory,
actionResultApplier = actionResultApplier
).results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
val appliedResults = actionResultApplier.applyActionResults(currentState, vassalCommandResults)
applier = ActionResultTApplierImpl(ScalaRuntimeValidator)
).execute(currentState, actionResultProtoApplier)
if appliedResults.nonEmpty then appliedResults
if vassalCommandResults.nonEmpty then vassalCommandResults
else
Vector(
actionResultApplier.applyActionResult(
actionResultProtoApplier.applyActionResult(
currentState,
EndDefenseDecisionPhaseAction(currentState).immediateExecute
ActionResultProtoConverter.toProto(
EndDefenseDecisionPhaseAction(GameStateConverter.fromProto(currentState)).immediateExecute
)
)
)
}
case TruceTurnBack =>
val actionResults = TruceTurnBackPhaseAction(currentState, actionResultApplier)
.results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, actionResults)
case TRUCE_TURN_BACK =>
TruceTurnBackPhaseAction(GameStateConverter.fromProto(currentState)).execute(actionResultProtoApplier)
case BattleRequest =>
case BATTLE_REQUEST =>
val requestBattlesAction = RequestBattlesAction(
gameId = currentState.gameId,
currentRoundId = currentState.currentRoundId,
currentDate = currentState.currentDate.get,
battleCounter = currentState.battleCounter,
heroes = currentState.heroes,
battalions = currentState.battalions,
provinces = currentState.provinces,
factions = currentState.factions,
battalionTypes = currentState.battalionTypes.map(bt => bt.typeId -> bt).toMap
GameStateConverter.fromProto(currentState)
)
val requestResults = actionResultApplier.applyActionResults(
val requestResults = actionResultProtoApplier.applyActionResults(
currentState,
requestBattlesAction.results
requestBattlesAction.results.map(
ActionResultProtoConverter.toProto(_)
)
)
val latestState = requestResults.lastOption.map(_.resultingState).getOrElse(currentState)
requestResults :+ actionResultApplier.applyActionResult(
val latestState = requestResults.lastOption.map(_.gameState).getOrElse(currentState)
requestResults :+ actionResultProtoApplier.applyActionResult(
latestState,
EndBattleRequestPhaseAction(latestState).immediateExecute
)
case FoodConsumption =>
Vector(
actionResultApplier.applyActionResult(
currentState,
PerformFoodConsumptionPhaseAction(currentState).immediateExecute
ActionResultProtoConverter.toProto(
EndBattleRequestPhaseAction(GameStateConverter.fromProto(latestState)).immediateExecute
)
)
case BattleResolution =>
case FOOD_CONSUMPTION =>
Vector(
actionResultProtoApplier.applyActionResult(
currentState,
ActionResultProtoConverter.toProto(
PerformFoodConsumptionPhaseAction(GameStateConverter.fromProto(currentState)).immediateExecute
)
)
)
case BATTLE_RESOLUTION =>
if currentState.outstandingBattles.isEmpty then
Vector(
actionResultApplier.applyActionResult(
actionResultProtoApplier.applyActionResult(
currentState,
EndBattleResolutionPhaseAction.immediateExecute
ActionResultProtoConverter.toProto(EndBattleResolutionPhaseAction.immediateExecute)
)
)
else Vector.empty // wait for battles to resolve
case BattleAftermath =>
case BATTLE_AFTERMATH =>
if currentState.provinces.values
.flatMap(_.capturedHeroes)
.isEmpty
then
actionResultApplier.applyActionResults(
actionResultProtoApplier.applyActionResults(
currentState,
EndBattleAftermathPhaseAction(
currentState,
actionResultApplier
GameStateConverter.fromProto(currentState),
ActionResultTApplierImpl(ScalaRuntimeValidator)
)
.randomResults(
SeededRandom(currentState.randomSeed)
)
.newValue
.map(ActionResultProtoConverter.toProto)
)
else Vector.empty
case DiplomacyResolution =>
case DIPLOMACY_RESOLUTION =>
if availableCommandsFactory.hasAvailablePlayerCommands(
currentStateProto
currentState
)
then Vector.empty
else
actionResultApplier.applyActionResults(
actionResultProtoApplier.applyActionResults(
currentState,
EndDiplomacyResolutionPhaseAction(
currentState,
actionResultApplier = actionResultApplier
).randomResults(SeededRandom(currentState.randomSeed)).newValue
GameStateConverter.fromProto(currentState),
actionResultTApplier = ActionResultTApplierImpl(ScalaRuntimeValidator)
).randomResults(SeededRandom(currentState.randomSeed))
.newValue
.map(ActionResultProtoConverter.toProto)
)
case ReconResolution =>
val actionResults = PerformReconResolutionAction(currentState, actionResultApplier)
.results(SeededRandom(currentState.randomSeed))
.map(VigorXPApplier.withVigorXp)
actionResultApplier.applyActionResults(currentState, actionResults)
case RECON_RESOLUTION =>
PerformReconResolutionAction(GameStateConverter.fromProto(currentState)).execute(
actionResultProtoApplier
)
case Unrecognized(x) =>
throw new IllegalStateException(s"Unknown round phase $x")
}
val timeSpent = System.currentTimeMillis - startTime
val timeSpent = System.currentTimeMillis - startTime
times(currentPhase) = times(currentPhase) + timeSpent
validateResults(results, currentState, currentStateProto, availableCommandsFactory)
validateResults(results, currentState, availableCommandsFactory)
}
// We should always either return results, be waiting for an LLM request or battle to resolve,
// or have available player commands
private def validateResults(
results: Vector[ActionResultWithResultingState],
results: Vector[ActionWithResultingState],
startingState: GameState,
startingStateProto: GameStateProto,
availableCommandsFactory: AvailableCommandsFactory
): Vector[ActionResultWithResultingState] =
): Vector[ActionWithResultingState] =
internalValidated(
results,
(r: Vector[ActionResultWithResultingState]) =>
(r: Vector[ActionWithResultingState]) =>
r.nonEmpty ||
startingState.outstandingBattles.nonEmpty ||
availableCommandsFactory.hasAvailablePlayerCommands(startingStateProto),
availableCommandsFactory.hasAvailablePlayerCommands(startingState),
"No results were found, but we also don't seem to be waiting for anything"
)
}
@@ -114,7 +114,6 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
// Apply all remaining entity changes using extension methods
val finalState = stateAfterChangedBattalions
.applyProvinceActed(result.provinceIdActed)
.applyLastCommand(result.provinceId, result.lastCommandTypeForActingProvince)
.applyNewBattalions(result.newBattalions, result.provinceId)
.applyDestroyedBattalionIds(result.destroyedBattalionIds)
.applyNewHeroes(result.newHeroes)
@@ -54,7 +54,6 @@ scala_library(
deps = [
":province_update_helpers",
":province_update_helpers2",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
@@ -107,6 +106,7 @@ scala_library(
srcs = ["GameStateFactionExtensions.scala"],
visibility = ["//visibility:private"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -114,7 +114,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -155,7 +154,6 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -171,7 +169,6 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
@@ -9,7 +9,7 @@ import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.faction.FactionT.OutgoingOfferRound
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.view.province.ProvinceView
import net.eagle0.eagle.views.province_view.ProvinceView
object GameStateFactionExtensions {
@@ -16,13 +16,7 @@ object GameStateMiscExtensions {
def applyNewNotifications(notifications: Vector[NotificationT]): GameState =
if notifications.isEmpty then gameState
else {
// Only add deferred notifications to the deferred list.
// Non-deferred notifications are for immediate delivery and don't affect game state.
val deferredOnly = notifications.filter(_.deferred)
if deferredOnly.isEmpty then gameState
else gameState.copy(deferredNotifications = gameState.deferredNotifications ++ deferredOnly)
}
else gameState.copy(deferredNotifications = gameState.deferredNotifications ++ notifications)
def applyRemovedNotifications(notifications: Vector[NotificationT]): GameState =
gameState.copy(
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.*
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
@@ -76,22 +75,5 @@ object GameStateProvinceExtensions {
}
.getOrElse(gameState)
}
def applyLastCommand(
provinceId: Option[ProvinceId],
lastCommand: Option[SelectedCommand]
): GameState =
provinceId
.filter(_ => lastCommand.exists(!_.isEmpty))
.map { pid =>
val province = gameState.provinces(pid) match {
case p: ProvinceC => p
case p => throw new EagleInternalException(s"Unknown ProvinceT type: ${p.getClass}")
}
gameState.copy(
provinces = gameState.provinces.updated(pid, province.copy(lastCommand = lastCommand))
)
}
.getOrElse(gameState)
}
}
@@ -172,19 +172,25 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_proto_sequencer",
],
deps = [
":check_for_faction_changes_action",
":hero_backstory_update_action_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battle_revelation_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_proto_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_request_generators/captured_hero_helpers:captured_hero_plea_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_against_former_on_exile",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_from_exile",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_from_imprisonment",
@@ -207,14 +213,20 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:captured_hero_resolved_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_aftermath_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battle_revelation_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:battle_revelation",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/faction:faction_relationship",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province:deferred_change_trait",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
@@ -253,6 +265,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_attack_decision_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
@@ -317,7 +330,6 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
@@ -326,17 +338,20 @@ scala_library(
":check_for_faction_changes_action",
":hero_backstory_update_action",
":hero_backstory_update_action_generator",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action/diplomacy_helpers:alliance_resolution_helpers",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action/diplomacy_helpers:break_alliance_resolution_helpers",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action/diplomacy_helpers:invitation_resolution_helpers",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action/diplomacy_helpers:ransom_resolution_helpers",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action/diplomacy_helpers:truce_resolution_helpers",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/library/util/ransom_validity",
@@ -349,17 +364,20 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_diplomacy_resolution_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:ransom_invalidated_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/diplomacy_offer",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/hero/backstory_version",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
@@ -382,15 +400,20 @@ scala_library(
deps = [
":check_for_faction_changes_action",
":hero_backstory_update_action_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:vigor_xp_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/settings:prisoner_escape_chance",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
@@ -408,11 +431,20 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types:prisoner_escaped_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:prisoner_move_took_effect_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:weather_took_effect_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:unaffiliated_hero_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:event",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province:deferred_change_trait",
"//src/main/scala/net/eagle0/eagle/model/state/province:event",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -479,24 +511,32 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:handle_riot_utils",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:t_command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_command",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_handle_riots_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -671,7 +711,7 @@ scala_library(
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
],
deps = [
":check_for_faction_changes_action",
@@ -680,9 +720,9 @@ scala_library(
":hero_backstory_update_action_generator",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
@@ -694,7 +734,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_vassal_commands_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/date",
@@ -800,6 +844,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
@@ -851,18 +896,23 @@ scala_library(
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
],
deps = [
":chronicle_event_generator",
":hero_stat_gain_action",
":new_year_action",
"//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/settings:empty_province_monthly_devastation_delta",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_minimum_adjustment_per_round",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_multiplier_per_round",
@@ -871,9 +921,10 @@ scala_library(
"//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:trust_delta_per_round",
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:price_index_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -886,13 +937,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:new_round_action_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:unaffiliated_hero_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/chronicle_entry",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
@@ -1107,9 +1163,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/battalion/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
@@ -1195,21 +1251,41 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
],
deps = [
":end_province_move_resolution_phase_action",
":friendly_move_action",
":shipment_arrived_action",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:army_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:supplies_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
],
)
@@ -1260,15 +1336,15 @@ scala_library(
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/util:returning_heroes",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -1277,16 +1353,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:client_text_visibility_extension_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_recon_resolution_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:recon_succeeded_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:incoming_end_turn_action_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province:incoming_end_turn_action",
],
)
@@ -1298,20 +1376,26 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator",
],
deps = [
":end_unaffiliated_hero_actions_phase_action",
":unaffiliated_hero_appeared_action",
":unaffiliated_hero_rejoined_action",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/name_generation_request",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/settings:free_hero_move_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_free_hero_move",
"//src/main/scala/net/eagle0/eagle/library/settings:new_hero_chance",
@@ -1342,6 +1426,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types:hero_changed_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:hero_moved_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:new_quests_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
@@ -1361,31 +1449,40 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:t_command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:t_command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_command",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -1397,27 +1494,36 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:t_command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:t_command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_command",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -1592,6 +1698,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1766,16 +1873,16 @@ scala_library(
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
],
deps = [
":withdrawn_army_returns_home_action",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
@@ -1788,6 +1895,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_truce_turn_back_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:withdrawal_for_truce_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
@@ -1832,6 +1940,35 @@ scala_library(
],
)
scala_library(
name = "unaffiliated_hero_moved_action",
srcs = ["UnaffiliatedHeroMovedAction.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/settings:free_hero_move_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/util/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:unaffiliated_hero_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
scala_library(
name = "unaffiliated_hero_rejoined_action",
srcs = ["UnaffiliatedHeroRejoinedAction.scala"],
@@ -1,23 +1,23 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.eagle.{GameId, RoundId}
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.EndAttackDecisionPhaseResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.concrete.TruceWithFactionQuest
import net.eagle0.eagle.model.state.quest.QuestT
import net.eagle0.eagle.model.state.RoundPhase
case class EndAttackDecisionPhaseAction(
gameId: GameId,
currentRoundId: RoundId,
currentDate: Date,
provinces: Vector[ProvinceT]
gameState: GameState
) extends ProtolessSequentialResultsAction {
private val gameId = gameState.gameId
private val currentRoundId = gameState.currentRoundId
private val currentDate = gameState.currentDate.get
private val provinces = gameState.provinces.values.toVector
override def results: Vector[ActionResultT] =
WithdrawnArmiesReturnHomeAction(
@@ -2,11 +2,25 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.internal.deferred_change.{
BlizzardEnded,
BlizzardStarted,
CapturedHeroExecuted,
CapturedHeroExiled,
CapturedHeroImprisoned,
CapturedHeroReturned,
DeferredChange,
DroughtEnded,
DroughtStarted,
EpidemicStarted,
PrisonerMoved,
PrisonerReturned
}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.generated_text_request_generators.captured_hero_helpers.CapturedHeroPleaGenerator
import net.eagle0.eagle.library.actions.impl.action.EndBattleAftermathPhaseAction.RevelationChange
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, RandomStateTSequencer}
import net.eagle0.eagle.library.settings.{
FactionBiasAgainstFormerOnExile,
FactionBiasFromExile,
@@ -22,6 +36,12 @@ import net.eagle0.eagle.model.action_result.{ActionResultT, NotificationDetails,
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC, NotificationC}
import net.eagle0.eagle.model.action_result.types.{CapturedHeroResolvedResultType, EndAftermathPhaseResultType}
import net.eagle0.eagle.model.proto_converters.{BattleRevelationConverter, NotificationConverter}
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.{BattleRevelation, RoundPhase}
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.game_state.GameState
@@ -31,7 +51,6 @@ import net.eagle0.eagle.model.state.hero.{
CapturedHeroReturnedBackstoryEvent,
EventForHeroBackstoryT
}
import net.eagle0.eagle.model.state.province.{DeferredChange, DeferredChangeT}
import net.eagle0.eagle.model.state.unaffiliated_hero.concrete.UnaffiliatedHeroC
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroType
import net.eagle0.eagle.model.state.BattleRevelationType.{DidBattle, Unknown, Withdrew}
@@ -42,8 +61,8 @@ object EndBattleAftermathPhaseAction {
changedProvince: ChangedProvinceC
)
def allDeferredChanges(gameState: GameState): Vector[DeferredChangeT] =
gameState.provinces.values
def allDeferredChanges(gameStateProto: GameStateProto): Vector[DeferredChange] =
gameStateProto.provinces.values
.flatMap(_.deferredChanges)
.toVector
@@ -55,15 +74,16 @@ object EndBattleAftermathPhaseAction {
capturedHeroFactionId: FactionId,
newFactionBias: Option[Double],
oldFactionBias: Option[Double],
gameState: GameState,
gameStateProto: GameStateProto,
functionalRandom: FunctionalRandom,
newEventForHeroBackstoryDetails: EventForHeroBackstoryT
): RandomState[ActionResultT] = {
val hero = gameState.heroes(capturedHeroId)
val initialUh = UnaffiliatedHeroC(
val scalaGameState = GameStateConverter.fromProto(gameStateProto)
val scalaHero = scalaGameState.heroes(capturedHeroId)
val initialUh = UnaffiliatedHeroC(
heroId = capturedHeroId,
unaffiliatedHeroType = uhType,
lastFactionId = hero.factionId,
lastFactionId = scalaHero.factionId,
factionBiases = (
newFactionBias.map(b => actingFactionId -> b) ++ oldFactionBias.map(b => capturedHeroFactionId -> b)
).toMap
@@ -71,10 +91,10 @@ object EndBattleAftermathPhaseAction {
UnaffiliatedHeroUtils
.updatedForQuest(
gs = gameState,
gs = scalaGameState,
pid = provinceId,
uh = initialUh,
hero = hero,
hero = scalaHero,
functionalRandom = functionalRandom
)
.map { uh =>
@@ -101,22 +121,23 @@ object EndBattleAftermathPhaseAction {
}
def deferredChangeAR(
deferredChange: DeferredChangeT,
gameState: GameState,
deferredChange: DeferredChange,
gameStateProto: GameStateProto,
functionalRandom: FunctionalRandom
): RandomState[ActionResultT] =
deferredChange match {
case DeferredChange.CapturedHeroExiled(
case CapturedHeroExiled(
exiledHeroId,
provinceId,
exilingHeroId,
exilingFactionId,
prisonerFactionId
prisonerFactionId,
_ /* unknownFieldSet */
) =>
val notificationLlmRequest =
CapturedHeroPleaGenerator.exiledNotification(
gameId = gameState.gameId,
currentRoundId = gameState.currentRoundId,
gameId = gameStateProto.gameId,
currentRoundId = gameStateProto.currentRoundId,
capturedHeroId = exiledHeroId,
capturedFromFactionId = prisonerFactionId,
actingHeroId = exilingHeroId,
@@ -131,10 +152,10 @@ object EndBattleAftermathPhaseAction {
capturedHeroFactionId = prisonerFactionId,
newFactionBias = Some(FactionBiasFromExile.doubleValue),
oldFactionBias = Some(FactionBiasAgainstFormerOnExile.doubleValue),
gameState = gameState,
gameStateProto = gameStateProto,
functionalRandom = functionalRandom,
newEventForHeroBackstoryDetails = CapturedHeroExiledBackstoryEvent(
date = gameState.currentDate.get,
date = DateConverter.fromProto(gameStateProto.currentDate),
capturingFactionId = exilingFactionId,
capturingHeroId = exilingHeroId,
provinceId = provinceId
@@ -154,17 +175,18 @@ object EndBattleAftermathPhaseAction {
)
)
}
case DeferredChange.CapturedHeroExecuted(
case CapturedHeroExecuted(
capturedHeroId,
provinceId,
executingHeroId,
executingFactionId,
prisonerFactionId
prisonerFactionId,
_ /* unknownFieldSet */
) =>
val notificationLlmRequest =
CapturedHeroPleaGenerator.executedNotification(
gameId = gameState.gameId,
currentRoundId = gameState.currentRoundId,
gameId = gameStateProto.gameId,
currentRoundId = gameStateProto.currentRoundId,
capturedHeroId = capturedHeroId,
capturedFromFactionId = prisonerFactionId,
actingHeroId = executingHeroId,
@@ -200,17 +222,18 @@ object EndBattleAftermathPhaseAction {
),
functionalRandom
)
case DeferredChange.CapturedHeroImprisoned(
case CapturedHeroImprisoned(
capturedHeroId,
provinceId,
imprisoningHeroId,
imprisoningFactionId,
prisonerFactionId
prisonerFactionId,
_ /* unknownFieldSet */
) =>
val notificationLlmRequest =
CapturedHeroPleaGenerator.imprisonedNotification(
gameId = gameState.gameId,
currentRoundId = gameState.currentRoundId,
gameId = gameStateProto.gameId,
currentRoundId = gameStateProto.currentRoundId,
capturedHeroId = capturedHeroId,
actingHeroId = imprisoningHeroId,
actingFactionId = imprisoningFactionId,
@@ -225,10 +248,10 @@ object EndBattleAftermathPhaseAction {
capturedHeroFactionId = prisonerFactionId,
newFactionBias = Some(FactionBiasFromImprisonment.doubleValue),
oldFactionBias = None,
gameState = gameState,
gameStateProto = gameStateProto,
functionalRandom = functionalRandom,
newEventForHeroBackstoryDetails = CapturedHeroImprisonedBackstoryEvent(
date = gameState.currentDate.get,
date = DateConverter.fromProto(gameStateProto.currentDate),
capturingFactionId = imprisoningFactionId,
capturingHeroId = imprisoningHeroId,
provinceId = provinceId
@@ -248,16 +271,22 @@ object EndBattleAftermathPhaseAction {
)
)
}
case DeferredChange.CapturedHeroReturned(
case CapturedHeroReturned(
returnedHeroId,
actingHeroId,
fromProvinceId,
fromFactionId,
toProvinceId,
toFactionId
toFactionId,
_ /* unknownFieldSet */
) =>
val allFactions = gameState.factions.values
val truceEndDate = gameState.currentDate.get.addMonths(TruceMonthsFromReturningLeader.intValue)
val allFactionTs =
gameStateProto.factions.values.map(FactionConverter.fromProto)
val truceEndDate = DateConverter
.fromProto(gameStateProto.currentDate)
.addMonths(
TruceMonthsFromReturningLeader.intValue
)
RandomState(
ActionResultC(
actionResultType = CapturedHeroResolvedResultType,
@@ -268,7 +297,7 @@ object EndBattleAftermathPhaseAction {
heroId = returnedHeroId,
newEventsForHeroBackstory = Vector(
CapturedHeroReturnedBackstoryEvent(
date = gameState.currentDate.get,
date = DateConverter.fromProto(gameStateProto.currentDate),
capturingFactionId = fromFactionId,
capturingHeroId = actingHeroId,
provinceId = fromProvinceId
@@ -295,7 +324,7 @@ object EndBattleAftermathPhaseAction {
.factionRelationship(
by = fromFactionId,
of = toFactionId,
factions = allFactions
factions = allFactionTs
)
.copy(
relationshipLevel = FactionRelationship.RelationshipLevel.Truce,
@@ -310,7 +339,7 @@ object EndBattleAftermathPhaseAction {
.factionRelationship(
by = toFactionId,
of = fromFactionId,
factions = allFactions
factions = allFactionTs
)
.copy(
relationshipLevel = FactionRelationship.RelationshipLevel.Truce,
@@ -334,10 +363,12 @@ object EndBattleAftermathPhaseAction {
functionalRandom
)
case DeferredChange.Empty =>
throw new EagleInternalException("Empty deferred change")
// the rest are not for this phase
case _: DeferredChange.EpidemicStarted | _: DeferredChange.DroughtStarted | _: DeferredChange.DroughtEnded |
_: DeferredChange.PrisonerMoved | _: DeferredChange.PrisonerReturned | _: DeferredChange.BlizzardStarted |
_: DeferredChange.BlizzardEnded =>
case _: EpidemicStarted | _: DroughtStarted | _: DroughtEnded | _: PrisonerMoved | _: PrisonerReturned |
_: BlizzardStarted | _: BlizzardEnded =>
throw new EagleInternalException(
"Event should not be present in EndBattleAftermathPhaseAction"
)
@@ -346,12 +377,12 @@ object EndBattleAftermathPhaseAction {
case class EndBattleAftermathPhaseAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
actionResultApplier: ActionResultTApplier
) extends ProtolessRandomSequentialResultsAction {
// Convert to proto for internal use - proto still needed for DeferredChange, ProvinceViewFilter, etc.
private val gameStateProto: GameStateProto = GameStateConverter.toProto(gameState)
private def revelationChange(
battleRevelation: BattleRevelation,
gs: GameState
battleRevelation: BattleRevelation
): RevelationChange =
RevelationChange(
changedFaction = battleRevelation.revelationType match {
@@ -363,21 +394,25 @@ case class EndBattleAftermathPhaseAction(
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.withdrawnFromProvinceView(
province = gs.provinces(battleRevelation.provinceId),
gs = gs,
province = gameStateProto.provinces(battleRevelation.provinceId),
gs = gameStateProto,
factionId = battleRevelation.revealedToFactionId
)
.copy(asOf = gs.currentDate)
.withAsOf(gameStateProto.currentDate.get)
)
)
case DidBattle =>
ChangedFactionC(
factionId = battleRevelation.revealedToFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter.filteredProvinceView(
gs.provinces(battleRevelation.provinceId),
gs
)
ProvinceViewFilter
.filteredProvinceView(
gameStateProto.provinces(battleRevelation.provinceId),
gameStateProto
// FIXME: setting factionId to None right now to grab the full info. This
// probably isn't exactly what we want.
)
.withAsOf(gameStateProto.currentDate.get)
)
)
},
@@ -387,24 +422,26 @@ case class EndBattleAftermathPhaseAction(
)
)
def revelationChanges(gs: GameState): Vector[RevelationChange] =
def revelationChanges(gs: GameStateProto): Vector[RevelationChange] =
for {
province <- gs.provinces.values.toVector
revelation <- province.battleRevelations
if gs.factions.contains(revelation.revealedToFactionId)
} yield revelationChange(revelation, gs)
} yield revelationChange(BattleRevelationConverter.fromProto(revelation))
def sequencerWithDeferredChanges(
initialState: GameState,
actionResultApplier: ActionResultApplier,
actionResultApplier: ActionResultTApplier,
functionalRandom: FunctionalRandom
): RandomStateSequencer =
RandomStateSequencer(
): RandomStateTSequencer =
RandomStateTSequencer(
initialState = initialState,
actionResultApplier = actionResultApplier,
functionalRandom = functionalRandom
)
.foldIn(EndBattleAftermathPhaseAction.allDeferredChanges(initialState))(
.foldIn(
EndBattleAftermathPhaseAction.allDeferredChanges(GameStateConverter.toProto(initialState))
)(
EndBattleAftermathPhaseAction.deferredChangeAR
)
@@ -430,25 +467,30 @@ case class EndBattleAftermathPhaseAction(
.withRandomActionResults((gs, fr) =>
CheckForFactionChangesAction(
gameId = gs.gameId,
factions = gs.factions.values.toVector,
provinces = gs.provinces.values.toVector,
heroes = gs.heroes.values.toVector,
factions = gs.factions.values.toVector.map(FactionConverter.fromProto),
provinces = gs.provinces.values.toVector.map(ProvinceConverter.fromProto),
heroes = gs.heroes.values.toVector.map(HeroConverter.fromProto),
killedHeroIds = gs.killedHeroes.keys.toVector
).randomResults(fr)
)
.withActionResults(gs => HeroBackstoryUpdateActionGenerator(gs).results)
.withRandomActionResult { (gs, fr) =>
RandomState(
ActionResultC(
actionResultType = EndAftermathPhaseResultType,
changedFactions = revelationChanges(gs).map(_.changedFaction),
changedProvinces = revelationChanges(gs).map(_.changedProvince),
newRoundPhase = Some(RoundPhase.DiplomacyResolution),
removedNotifications = gs.deferredNotifications.map(_.withDeferred(true)),
newNotifications = gs.deferredNotifications.map(_.withDeferred(false))
),
fr
)
.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator.fromGameState(gs))
.withRandomActionResult {
case (gs, fr) =>
RandomState(
ActionResultC(
actionResultType = EndAftermathPhaseResultType,
changedFactions = revelationChanges(gs).map(_.changedFaction),
changedProvinces = revelationChanges(gs).map(_.changedProvince),
newRoundPhase = Some(RoundPhase.DiplomacyResolution),
removedNotifications = gs.deferredNotifications
.map(note => NotificationConverter.fromProto(note, deferred = true))
.toVector,
newNotifications = gs.deferredNotifications
.map(note => NotificationConverter.fromProto(note, deferred = false))
.toVector
),
fr
)
}
.actionResults
}
@@ -1,7 +1,8 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.action.diplomacy_helpers.{
AllianceResolutionHelpers,
BreakAllianceResolutionHelpers,
@@ -9,8 +10,7 @@ import net.eagle0.eagle.library.actions.impl.action.diplomacy_helpers.{
RansomResolutionHelpers,
TruceResolutionHelpers
}
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, RandomStateTSequencer}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentChecker
import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
@@ -18,6 +18,11 @@ import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC}
import net.eagle0.eagle.model.action_result.types.{EndDiplomacyResolutionPhaseResultType, RansomInvalidatedResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.{BattalionConverter, NotificationConverter}
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.diplomacy_offer.{AllianceOffer, BreakAlliance, Invitation, RansomOffer, TruceOffer}
@@ -30,16 +35,16 @@ import net.eagle0.eagle.model.state.RoundPhase.ReconResolution
case class EndDiplomacyResolutionPhaseAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
actionResultTApplier: ActionResultTApplier
) extends ProtolessRandomSequentialResultsAction {
override def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] = {
import EndDiplomacyResolutionPhaseActionHelpers.*
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = actionResultTApplier,
functionalRandom = functionalRandom
)
.withActionResults(invalidationResultsForState)
@@ -51,9 +56,15 @@ case class EndDiplomacyResolutionPhaseAction(
.withRandomActionResults { (currentGameState, fr) =>
CheckForFactionChangesAction(
gameId = currentGameState.gameId,
factions = currentGameState.factions.values.toVector,
provinces = currentGameState.provinces.values.toVector,
heroes = currentGameState.heroes.values.toVector,
factions = currentGameState.factions.values.toVector.map(
FactionConverter.fromProto
),
provinces = currentGameState.provinces.values.toVector.map(
ProvinceConverter.fromProto
),
heroes = currentGameState.heroes.values.toVector.map(
HeroConverter.fromProto
),
killedHeroIds = currentGameState.killedHeroes.keys.toVector
).randomResults(fr)
}
@@ -61,14 +72,20 @@ case class EndDiplomacyResolutionPhaseAction(
HeroBackstoryUpdateAction(
gameId = currentGameState.gameId,
roundId = currentGameState.currentRoundId,
heroes = currentGameState.heroes.values.toVector,
heroes = currentGameState.heroes.values
.map(HeroConverter.fromProto)
.toVector,
visibleToFactionIds = toFid =>
FactionUtils.alliedFactions(
toFid,
currentGameState.factions.values.toVector
currentGameState.factions.values
.map(FactionConverter.fromProto)
.toVector
),
heroInProvinceOwnedBy = heroId => {
val provinces = currentGameState.provinces.values.toVector
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
provinces
.find(province =>
province.rulingFactionHeroIds.contains(heroId) ||
@@ -92,16 +109,19 @@ case class EndDiplomacyResolutionPhaseAction(
* compiler can become confused about which one is being referenced during imports.
*
* All helper methods take explicit parameters for game state components to ensure they always operate on the current
* state as provided by RandomStateSequencer, preventing use of stale data from the initial game state.
* state as provided by RandomStateTSequencer, preventing use of stale data from the initial game state.
*/
private object EndDiplomacyResolutionPhaseActionHelpers {
// Methods that use the updated game state from the sequencer
def invalidationResultsForState(
currentGameState: GameState
currentGameState: GameStateProto
): Vector[ActionResultT] = {
val factions = currentGameState.factions.values.toVector
val provinces = currentGameState.provinces.values.toVector
val factions =
currentGameState.factions.values.map(FactionConverter.fromProto).toVector
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
for {
faction <- factions
@@ -129,9 +149,11 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
}
def endPhaseResultForState(
currentGameState: GameState
currentGameState: GameStateProto
): ActionResultT = {
val deferredNotifications = currentGameState.deferredNotifications
.map(n => NotificationConverter.fromProto(n, deferred = true))
.toVector
ActionResultC(
actionResultType = EndDiplomacyResolutionPhaseResultType,
@@ -141,6 +163,9 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
)
}
private def factionTs(gs: GameStateProto): Vector[FactionT] =
gs.factions.values.map(FactionConverter.fromProto).toVector
private def resolutionsForType[A](
getter: FactionT => Vector[A],
resolver: A => Vector[ActionResultT]
@@ -164,15 +189,17 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
}
def truceResolutionsForState(
currentGameState: GameState,
currentGameState: GameStateProto,
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] = {
val factions = currentGameState.factions.values.toVector
val factions = factionTs(currentGameState)
randomResolutionsForType(
_.incomingDiplomacyOffers.collect { case to: TruceOffer => to },
(to: TruceOffer, fr: FunctionalRandom) => {
val provinces = currentGameState.provinces.values.toVector
val currentDate = currentGameState.currentDate.get
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
val currentDate = DateConverter.fromProto(currentGameState.currentDate)
resultsForTruceOffer(
to,
fr,
@@ -187,16 +214,19 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
}
def allianceResolutionsForState(
currentGameState: GameState,
currentGameState: GameStateProto,
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] = {
val factions = currentGameState.factions.values.toVector
val factions = factionTs(currentGameState)
randomResolutionsForType(
_.incomingDiplomacyOffers.collect { case ao: AllianceOffer => ao },
(ao: AllianceOffer, fr: FunctionalRandom) => {
val provinces = currentGameState.provinces.values.toVector
val heroes = currentGameState.heroes.values.toVector
val currentDate = currentGameState.currentDate.get
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
val heroes =
currentGameState.heroes.values.map(HeroConverter.fromProto).toVector
val currentDate = DateConverter.fromProto(currentGameState.currentDate)
resultsForAllianceOffer(
ao,
fr,
@@ -211,15 +241,17 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
}
def breakAllianceResolutionsForState(
currentGameState: GameState,
currentGameState: GameStateProto,
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] = {
val factions = currentGameState.factions.values.toVector
val factions = factionTs(currentGameState)
randomResolutionsForType(
_.incomingDiplomacyOffers.collect { case ba: BreakAlliance => ba },
(ba: BreakAlliance, fr: FunctionalRandom) => {
val provinces = currentGameState.provinces.values.toVector
val currentDate = currentGameState.currentDate.get
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
val currentDate = DateConverter.fromProto(currentGameState.currentDate)
resultsForBreakAlliance(ba, fr, provinces, factions, currentDate)
},
functionalRandom
@@ -227,16 +259,20 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
}
def inviteResolutionsForState(
currentGameState: GameState,
currentGameState: GameStateProto,
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] = {
val factions = currentGameState.factions.values.toVector
val factions = factionTs(currentGameState)
randomResolutionsForType(
_.incomingDiplomacyOffers.collect { case id: Invitation => id },
(id: Invitation, fr: FunctionalRandom) => {
val provinces = currentGameState.provinces.values.toVector
val battalions = currentGameState.battalions.values.toVector
val currentDate = currentGameState.currentDate.get
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
val battalions = currentGameState.battalions.values
.map(BattalionConverter.fromProto)
.toVector
val currentDate = DateConverter.fromProto(currentGameState.currentDate)
resultsForInvitation(
id,
fr,
@@ -251,16 +287,18 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
}
def ransomResolutionsForState(
currentGameState: GameState
currentGameState: GameStateProto
): Vector[ActionResultT] = {
val factions = currentGameState.factions.values.toVector
val factions = factionTs(currentGameState)
resolutionsForType(
_.incomingDiplomacyOffers.collect { case ro: RansomOffer => ro },
(ro: RansomOffer) => {
val provinces = currentGameState.provinces.values.toVector
val provinces = currentGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector
resultsForRansomOffer(
ro,
currentGameState.currentDate.get,
DateConverter.fromProto(currentGameState.currentDate),
provinces,
currentGameState
)
@@ -272,7 +310,7 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
ransomOffer: RansomOffer,
currentDate: Date,
provinces: Vector[ProvinceT],
gameState: GameState
gameState: GameStateProto
): Vector[ActionResultT] = ransomOffer.status match {
case Accepted =>
RansomResolutionHelpers.acceptedRansomResults(
@@ -283,7 +321,7 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
gameId = gameState.gameId,
currentDate = currentDate,
currentRoundId = gameState.currentRoundId,
previousBackstoryTextIdLookup = hid => gameState.heroes(hid).backstoryVersions.last.textId
previousBackstoryTextIdLookup = hid => gameState.heroes(hid).backstoryVersions.toVector.last.textId
)
)
case Rejected =>
@@ -310,7 +348,7 @@ private object EndDiplomacyResolutionPhaseActionHelpers {
provinces: Vector[ProvinceT],
factions: Vector[FactionT],
currentDate: Date,
gameState: GameState
gameState: GameStateProto
): RandomState[Vector[ActionResultT]] =
truceOffer.status match {
case Accepted =>
@@ -2,93 +2,78 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.command.{HandleRiotUtils, TCommandFactory}
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.command.{CommandFactory, HandleRiotUtils}
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, RandomStateTSequencer}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.EndHandleRiotsPhaseResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.ProvinceId
case class EndHandleRiotsPhaseAction(
gameState: GameState,
commandsForProvince: ProvinceId => Option[OneProvinceAvailableCommands],
commandFactory: TCommandFactory,
actionResultApplier: ActionResultApplier
commandFactory: CommandFactory,
applier: ActionResultTApplier
) extends ProtolessRandomSequentialResultsAction {
private def provincesWithImminentRiot(gs: GameState): Vector[ProvinceT] =
gs.provinces.values.filter(ProvinceUtils.hasImminentRiot).toVector
private def vassalCommandResults(
sequencer: RandomStateSequencer
): RandomStateSequencer = {
val provincesToProcess = provincesWithImminentRiot(sequencer.lastState)
ars: RandomStateTSequencer
): RandomStateTSequencer =
ars.lastStateProto.provinces.values
.filter(LegacyProvinceUtils.hasImminentRiot)
.filterNot(_.hasActed)
provincesToProcess.foldLeft(sequencer) {
case (seq, province) =>
commandsForProvince(province.id).map { opac =>
seq.withRandomActionResults { (gs, fr) =>
// Convert to proto for CommandChoiceHelpers which expects proto GameState
val gsProto = GameStateConverter.toProto(gs)
CommandChoiceHelpers
.handleRiotSelectedCommand(
actingFactionId = province.rulingFactionId.get,
gameState = gsProto,
availableCommands = opac.commands.toVector,
functionalRandom = fr
)
.continue {
case (Some(cs), nextFr) =>
val cmd = commandFactory.makeTCommand(
actingFactionId = province.rulingFactionId.get,
gameState = gs,
availableCommand = cs.available,
selectedCommand = cs.selected
)
cmd match {
case TCommand.Simple(action) =>
RandomState(Vector(action.immediateExecute), nextFr)
case TCommand.RandomSimple(action) =>
action.immediateExecute(nextFr).map(ar => Vector(ar))
case TCommand.Sequential(action) =>
RandomState(action.results, nextFr)
.foldLeft(ars) {
case (sequencer, p) =>
commandsForProvince(p.id).map { opac =>
sequencer.withOptionalRandomTCommand { (gs, fr) =>
CommandChoiceHelpers
.handleRiotSelectedCommand(
actingFactionId = p.getRulingFactionId,
gameState = gs,
availableCommands = opac.commands.toVector,
functionalRandom = fr
)
.map { optCS =>
optCS.map { cs =>
commandFactory.makeTCommand(
actingFactionId = p.getRulingFactionId,
gameState = GameStateConverter.fromProto(gs),
availableCommand = cs.available,
selectedCommand = cs.selected
)
}
case (None, nextFr) =>
RandomState(Vector.empty[ActionResultT], nextFr)
}
}
}
.getOrElse(seq)
}
}
}
}
}.get
}
private def riotOccurredResults(
sequencer: RandomStateSequencer
): RandomStateSequencer =
provincesWithImminentRiot(sequencer.lastState).foldLeft(sequencer) {
case (seq, province) =>
seq.withActionResult(_ =>
HandleRiotUtils.riotOccurredAr(
province.rulingFactionId.get,
province
ars: RandomStateTSequencer
): RandomStateTSequencer =
ars.lastStateProto.provinces.values
.filter(LegacyProvinceUtils.hasImminentRiot)
.foldLeft(ars) {
case (ars, p) =>
ars.withActionResult(_ =>
HandleRiotUtils
.riotOccurredAr(
p.getRulingFactionId,
ProvinceConverter.fromProto(p)
)
)
)
}
}
private def endPhaseResult(
sequencer: RandomStateSequencer
): RandomStateSequencer =
sequencer.withActionResult { gs =>
ars: RandomStateTSequencer
): RandomStateTSequencer =
ars.withActionResultT(gs =>
ActionResultC(
actionResultType = EndHandleRiotsPhaseResultType,
newRoundPhase = Some(RoundPhase.HeroDepartures),
@@ -96,17 +81,17 @@ case class EndHandleRiotsPhaseAction(
.filter(_.hasActed)
.map(p => ChangedProvinceC(provinceId = p.id, setHasActed = Some(false)))
.toVector,
removedNotifications = gs.deferredNotifications,
newNotifications = gs.deferredNotifications.map(_.withDeferred(false))
removedNotifications = gameState.deferredNotifications,
newNotifications = gameState.deferredNotifications.map(_.withDeferred(false))
)
}
)
override def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] =
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = applier,
functionalRandom = functionalRandom
)
.withRandomContinuance(vassalCommandResults)
@@ -1,12 +1,19 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, VigorXPApplier}
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.internal.deferred_change.*
import net.eagle0.eagle.internal.deferred_change.DeferredChange.Empty
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.common.{
ProtolessRandomSequentialResultsAction,
RandomStateTSequencer,
VigorXPApplier
}
import net.eagle0.eagle.library.settings.PrisonerEscapeChance
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.ProvinceEventUtils
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.changed_province.ChangedProvinceT
@@ -19,14 +26,14 @@ import net.eagle0.eagle.model.action_result.types.{
WeatherTookEffectResultType
}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.{NotificationConverter, UnaffiliatedHeroConverter}
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.{ProvinceConverter, ProvinceEventConverter}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.{
BlizzardEvent,
DeferredChange,
DeferredChangeT,
DroughtEvent,
EpidemicEvent
}
import net.eagle0.eagle.model.state.province.{BlizzardEvent, DroughtEvent, EpidemicEvent}
import net.eagle0.eagle.model.state.unaffiliated_hero.RecruitmentInfo
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroType.{Outlaw, Prisoner}
import net.eagle0.eagle.model.state.RoundPhase
@@ -34,11 +41,13 @@ import net.eagle0.eagle.ProvinceId
case class EndPlayerCommandsPhaseAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
applier: ActionResultTApplier
) extends ProtolessRandomSequentialResultsAction {
private val gameStateProto: GameStateProto = GameStateConverter.toProto(gameState)
private def endPhaseResult(gs: GameState): ActionResultT = {
private def endPhaseResult(gs: GameStateProto): ActionResultT = {
val abandonedProvinces: Vector[ChangedProvinceT] = gs.provinces.values
.map(ProvinceConverter.fromProto)
.filter(_.rulingFactionId.isDefined)
.flatMap(ProvinceUtils.checkedForAbandonment)
.toVector
@@ -57,22 +66,27 @@ case class EndPlayerCommandsPhaseAction(
)
)
},
removedNotifications = gs.deferredNotifications.map(_.withDeferred(true)),
newNotifications = gs.deferredNotifications.map(_.withDeferred(false))
removedNotifications = gameStateProto.deferredNotifications.map { note =>
NotificationConverter.fromProto(note, deferred = true)
}.toVector,
newNotifications = gameStateProto.deferredNotifications.map { note =>
NotificationConverter.fromProto(note, deferred = false)
}.toVector
)
)
}
private def onePrisonerMovedChange(
pid: ProvinceId,
prisonerMoved: DeferredChange.PrisonerMoved,
gs: GameState,
prisonerMoved: PrisonerMoved,
gs: GameStateProto,
fr: FunctionalRandom
): RandomState[ActionResultT] = {
val uh = gs
.provinces(pid)
.unaffiliatedHeroes
.find(_.heroId == prisonerMoved.heroId)
.map(UnaffiliatedHeroConverter.fromProto)
fr.nextDouble.map { doubleValue =>
val escaped = doubleValue < PrisonerEscapeChance.doubleValue
@@ -112,8 +126,8 @@ case class EndPlayerCommandsPhaseAction(
private def onePrisonerReturnedChange(
pid: ProvinceId,
prisonerReturned: DeferredChange.PrisonerReturned,
gs: GameState,
prisonerReturned: PrisonerReturned,
gs: GameStateProto,
fr: FunctionalRandom
): RandomState[ActionResultT] =
RandomState(
@@ -145,28 +159,28 @@ case class EndPlayerCommandsPhaseAction(
private def notPrisonerChange(
pid: ProvinceId,
deferredChange: DeferredChangeT,
gs: GameState,
deferredChange: DeferredChange,
gs: GameStateProto,
fr: FunctionalRandom
): RandomState[ActionResultT] = {
val currentDate = gs.currentDate.get
val province = gs.provinces(pid)
): RandomState[ActionResultT] =
RandomState(
VigorXPApplier.withVigorXp(
ActionResultC(
actionResultType = deferredChange match {
case _: DeferredChange.EpidemicStarted => EpidemicTookEffectResultType
case _: DeferredChange.BlizzardEnded => WeatherTookEffectResultType
case _: DeferredChange.BlizzardStarted => WeatherTookEffectResultType
case _: DeferredChange.DroughtStarted => WeatherTookEffectResultType
case _: DeferredChange.DroughtEnded => WeatherTookEffectResultType
case _: DeferredChange.PrisonerMoved | _: DeferredChange.PrisonerReturned |
_: DeferredChange.CapturedHeroImprisoned | _: DeferredChange.CapturedHeroExecuted |
_: DeferredChange.CapturedHeroExiled | _: DeferredChange.CapturedHeroReturned =>
case _: EpidemicStarted => EpidemicTookEffectResultType
case _: BlizzardEnded => WeatherTookEffectResultType
case _: BlizzardStarted => WeatherTookEffectResultType
case _: DroughtStarted => WeatherTookEffectResultType
case _: DroughtEnded => WeatherTookEffectResultType
case _: PrisonerMoved | _: PrisonerReturned | _: CapturedHeroImprisoned | _: CapturedHeroExecuted |
_: CapturedHeroExiled | _: CapturedHeroReturned =>
throw new EagleInternalException(
"Prisoner management changes should not be here"
)
case DeferredChange.Empty =>
throw new EagleInternalException(
"Empty deferred change should not be here"
)
},
provinceId = Some(pid),
changedProvinces = Vector(
@@ -174,31 +188,70 @@ case class EndPlayerCommandsPhaseAction(
provinceId = pid,
newProvinceEvents = Some(
deferredChange match {
case DeferredChange.BlizzardStarted(_, durationMonths) =>
province.activeEvents :+ BlizzardEvent(
startDate = currentDate,
endDate = currentDate.addMonths(durationMonths)
case BlizzardStarted(
_,
durationMonths,
_ /* unknownFieldSet */
) =>
gs.provinces(pid)
.activeEvents
.map(ProvinceEventConverter.fromProto)
.toVector :+ BlizzardEvent(
startDate = DateConverter.fromProto(gs.currentDate),
endDate = DateConverter
.fromProto(gs.currentDate)
.addMonths(
durationMonths
)
)
case _: DeferredChange.BlizzardEnded =>
province.activeEvents.filter { case _: BlizzardEvent => false; case _ => true }
case BlizzardEnded(_, _ /* unknownFieldSet */ ) =>
gs.provinces(pid)
.activeEvents
.filterNot(ProvinceEventUtils.isBlizzardEvent)
.map(ProvinceEventConverter.fromProto)
.toVector
case DeferredChange.DroughtStarted(_, durationMonths) =>
province.activeEvents :+ DroughtEvent(
startDate = currentDate,
endDate = currentDate.addMonths(durationMonths)
case DroughtStarted(
_,
durationMonths,
_ /* unknownFieldSet */
) =>
gs.provinces(pid)
.activeEvents
.map(ProvinceEventConverter.fromProto)
.toVector :+ DroughtEvent(
startDate = DateConverter.fromProto(gs.currentDate),
endDate = DateConverter
.fromProto(gs.currentDate)
.addMonths(durationMonths)
)
case _: DeferredChange.DroughtEnded =>
province.activeEvents.filter { case _: DroughtEvent => false; case _ => true }
case _: DeferredChange.EpidemicStarted =>
province.activeEvents :+ EpidemicEvent(startDate = currentDate)
case DroughtEnded(_, _ /* unknownFieldSet */ ) =>
gs.provinces(pid)
.activeEvents
.filterNot(ProvinceEventUtils.isDroughtEvent)
.map(ProvinceEventConverter.fromProto)
.toVector
case _: DeferredChange.PrisonerMoved => Vector()
case _: DeferredChange.PrisonerReturned => Vector()
case _: DeferredChange.CapturedHeroImprisoned => Vector()
case _: DeferredChange.CapturedHeroExecuted => Vector()
case _: DeferredChange.CapturedHeroExiled => Vector()
case _: DeferredChange.CapturedHeroReturned => Vector()
case EpidemicStarted(_, _ /* unknownFieldSet */ ) =>
gs.provinces(pid)
.activeEvents
.map(ProvinceEventConverter.fromProto)
.toVector :+ EpidemicEvent(
startDate = DateConverter.fromProto(gs.currentDate)
)
case _: PrisonerMoved => Vector()
case _: PrisonerReturned => Vector()
case _: CapturedHeroImprisoned => Vector()
case _: CapturedHeroExecuted => Vector()
case _: CapturedHeroExiled => Vector()
case _: CapturedHeroReturned => Vector()
case Empty =>
throw new EagleInternalException(
"Empty deferred change should not be here"
)
}
),
removedDeferredChangeIndex = Some(0)
@@ -208,27 +261,26 @@ case class EndPlayerCommandsPhaseAction(
),
fr
)
}
private def oneDeferredProvinceChange(
pid: ProvinceId,
deferredChange: DeferredChangeT,
gs: GameState,
deferredChange: DeferredChange,
gs: GameStateProto,
fr: FunctionalRandom
): RandomState[ActionResultT] =
deferredChange match {
case prisonerMoved: DeferredChange.PrisonerMoved =>
case prisonerMoved: PrisonerMoved =>
onePrisonerMovedChange(pid, prisonerMoved, gs, fr)
case prisonerReturned: DeferredChange.PrisonerReturned =>
case prisonerReturned: PrisonerReturned =>
onePrisonerReturnedChange(pid, prisonerReturned, gs, fr)
case _ => notPrisonerChange(pid, deferredChange, gs, fr)
case _ => notPrisonerChange(pid, deferredChange, gs, fr)
}
private def deferredProvinceChangesResultsForProvince(
pid: ProvinceId,
sequencer: RandomStateSequencer
): RandomStateSequencer =
sequencer.lastState.provinces(pid).deferredChanges.foldLeft(sequencer) {
arsRS: RandomStateTSequencer
): RandomStateTSequencer =
arsRS.lastStateProto.provinces(pid).deferredChanges.foldLeft(arsRS) {
case (acc, dc) =>
acc.withRandomActionResult {
case (gs, fr) =>
@@ -237,40 +289,41 @@ case class EndPlayerCommandsPhaseAction(
}
private def deferredProvinceChangesResults(
sequencer: RandomStateSequencer
): RandomStateSequencer =
sequencer.lastState.provinces.keys
.foldLeft(sequencer) {
case (newSequencer, pid) =>
deferredProvinceChangesResultsForProvince(pid, newSequencer)
ars: RandomStateTSequencer
): RandomStateTSequencer =
ars.lastStateProto.provinces.keys
.foldLeft(ars) {
case (newArs, pid) =>
deferredProvinceChangesResultsForProvince(pid, newArs)
}
override def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] = {
gameState.provinces.values.foreach { p =>
gameStateProto.provinces.values.foreach { p =>
internalRequire(
!p.rulerIsTraveling,
s"Leader is traveling at the end of the PlayerCommandsPhase in province ${p.id}"
)
}
RandomStateSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
functionalRandom = functionalRandom
)
RandomStateTSequencer
.fromProto(
initialStateProto = gameStateProto,
actionResultApplier = applier,
functionalRandom = functionalRandom
)
.withRandomActionResults((gs, fr) =>
CheckForFactionChangesAction(
gameId = gs.gameId,
factions = gs.factions.values.toVector,
provinces = gs.provinces.values.toVector,
heroes = gs.heroes.values.toVector,
factions = gs.factions.values.toVector.map(FactionConverter.fromProto),
provinces = gs.provinces.values.toVector.map(ProvinceConverter.fromProto),
heroes = gs.heroes.values.toVector.map(HeroConverter.fromProto),
killedHeroIds = gs.killedHeroes.keys.toVector
).randomResults(fr)
)
.withContinuance(deferredProvinceChangesResults)
.withActionResults(gs => HeroBackstoryUpdateActionGenerator(gs).results)
.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator.fromGameState(gs))
.withActionResult(endPhaseResult)
.actionResults
}
@@ -1,23 +1,24 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.common.{RandomStateTSequencer, TRandomSequentialResultsAction}
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.EndVassalCommandsPhaseResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.RoundPhase
case class EndVassalCommandsPhaseAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
case class EndVassalCommandsPhaseAction(gameState: GameState) extends TRandomSequentialResultsAction(gameState) {
override protected def randomResults(
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
actionResultTApplier: ActionResultTApplier
): RandomState[Vector[ActionResultT]] = {
gameState.provinces.values.foreach { p =>
internalRequire(
@@ -26,53 +27,56 @@ case class EndVassalCommandsPhaseAction(
)
}
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = actionResultTApplier,
functionalRandom = functionalRandom
)
.withActionResults(gs =>
.withProtolessSequentialResultsAction(gs =>
CheckForFulfilledQuestsAction(
gameId = gs.gameId,
currentDate = gs.currentDate.get,
currentDate = DateConverter.fromProto(gs.currentDate),
currentRoundId = gs.currentRoundId,
provinces = gs.provinces.values.toVector,
factions = gs.factions.values.toVector,
battalions = gs.battalions.values.toVector,
getHero = hid => gs.heroes.get(hid),
battalionTypes = gs.battalionTypes.map(BattalionTypeConverter.toProto),
hid => gs.heroes(hid).backstoryTextId
).results
provinces = gs.provinces.values.map(ProvinceConverter.fromProto).toVector,
factions = gs.factions.values
.map(FactionConverter.fromProto)
.toVector,
battalions = gs.battalions.values.toVector.map(BattalionConverter.fromProto),
getHero = hid => gs.heroes.get(hid).map(HeroConverter.fromProto),
battalionTypes = gs.battalionTypes.toVector,
hid => gs.heroes(hid).backstoryVersions.last.textId
)
)
.withActionResults(gs =>
.withProtolessSequentialResultsAction(gs =>
CheckForFailedQuestsAction(
gameId = gs.gameId,
currentDate = gs.currentDate.get,
currentDate = DateConverter.fromProto(gs.currentDate),
currentRoundId = gs.currentRoundId,
provinces = gs.provinces.values.toVector,
factions = gs.factions.values.toVector,
hid => gs.heroes(hid).backstoryTextId
).results
provinces = gs.provinces.values.map(ProvinceConverter.fromProto).toVector,
factions = gs.factions.values
.map(FactionConverter.fromProto)
.toVector,
hid => gs.heroes(hid).backstoryVersions.last.textId
)
)
.withRandomActionResults((gs, fr) =>
CheckForFactionChangesAction(
gameId = gs.gameId,
factions = gs.factions.values.toVector,
provinces = gs.provinces.values.toVector,
heroes = gs.heroes.values.toVector,
factions = gs.factions.values.map(FactionConverter.fromProto).toVector,
provinces = gs.provinces.values.map(ProvinceConverter.fromProto).toVector,
heroes = gs.heroes.values.map(HeroConverter.fromProto).toVector,
killedHeroIds = gs.killedHeroes.keys.toVector
).randomResults(fr)
)
.withActionResults(gs => HeroBackstoryUpdateActionGenerator(gs).results)
.withActionResult { gs =>
// Use current state's deferred notifications, not the initial gameState
.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator.fromGameState(gs))
.withActionResultT(_ =>
ActionResultC(
actionResultType = EndVassalCommandsPhaseResultType,
newRoundPhase = Some(RoundPhase.PlayerCommands),
removedNotifications = gs.deferredNotifications.map(_.withDeferred(true)),
newNotifications = gs.deferredNotifications.map(_.withDeferred(false))
removedNotifications = gameState.deferredNotifications,
newNotifications = gameState.deferredNotifications.map(_.withDeferred(false))
)
}
)
.actionResults
}
}
@@ -1,11 +1,14 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
object HeroBackstoryUpdateActionGenerator {
/** Creates action from Scala GameState (preferred) */
def apply(gameState: GameState): ProtolessSequentialResultsAction = {
val factions = gameState.factions.values.toVector
HeroBackstoryUpdateAction(
@@ -22,4 +25,8 @@ object HeroBackstoryUpdateActionGenerator {
.flatMap(_.rulingFactionId)
)
}
/** Creates action from proto GameState (for sequencer callbacks) */
def fromGameState(gameState: GameStateProto): ProtolessSequentialResultsAction =
apply(GameStateConverter.fromProto(gameState))
}
@@ -2,9 +2,9 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.common.{RandomStateTSequencer, TRandomSequentialResultsAction}
import net.eagle0.eagle.library.settings.{
EmptyProvinceMonthlyDevastationDelta,
FactionBiasMinimumAdjustmentPerRound,
@@ -15,7 +15,8 @@ import net.eagle0.eagle.library.settings.{
OverResourceLimitLoss,
TrustDeltaPerRound
}
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.DateProtoUtils._Date
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.PriceIndexUtils
import net.eagle0.eagle.library.GameHistory
@@ -31,34 +32,37 @@ import net.eagle0.eagle.model.action_result.concrete.{
import net.eagle0.eagle.model.action_result.generated_text_request.{ChronicleUpdatePreviousEntry, LlmRequestT}
import net.eagle0.eagle.model.action_result.types.NewRoundActionResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.generated_text_request.chronicle_event.ChronicleEventConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.UnaffiliatedHeroConverter
import net.eagle0.eagle.model.state.chronicle_entry.ChronicleEntry
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT
import net.eagle0.eagle.model.state.RoundPhase
case class NewRoundAction(
gameState: GameState,
gameHistory: GameHistory,
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
case class NewRoundAction(gameState: GameState, gameHistory: GameHistory)
extends TRandomSequentialResultsAction(gameState) {
private val startingState: GameStateProto = GameStateConverter.toProto(gameState)
private val chronicleMonths = Vector(11)
private def isChronicleMonth(newDate: Date): Boolean =
chronicleMonths.contains(newDate.month.value)
override def randomResults(
functionalRandom: FunctionalRandom
override protected def randomResults(
functionalRandom: FunctionalRandom,
actionResultTApplier: ActionResultTApplier
): RandomState[Vector[ActionResultT]] = {
val newRoundId = gameState.currentRoundId + 1
val newRoundId = startingState.currentRoundId + 1
// Verify no old incoming armies
gameState.provinces.foreach {
startingState.provinces.foreach {
case (pid, province) =>
internalRequire(
province.incomingArmies.forall(_.arrivalRound >= newRoundId),
@@ -70,26 +74,28 @@ case class NewRoundAction(
)
}
val oldDate = gameState.currentDate.get
val oldDate = DateConverter.fromProto(startingState.currentDate)
val newDate = oldDate.addMonths(1)
// Build sequencer starting from starting state
val initialSequencer = RandomStateSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
val initialSequencer = RandomStateTSequencer.fromProto(
initialStateProto = startingState,
actionResultApplier = actionResultTApplier,
functionalRandom = functionalRandom
)
// Optionally add NewYearAction
val afterNewYearSequencer =
if newDate.month == Date.Month.January then
initialSequencer.withActionResult(_ => NewYearAction(gameState).immediateExecute)
initialSequencer.withActionResultT(_ =>
NewYearAction(GameStateConverter.fromProto(startingState)).immediateExecute
)
else initialSequencer
// Add the main new round result
val afterNewRoundSequencer = afterNewYearSequencer.withActionResult { currentState =>
val afterNewRoundSequencer = afterNewYearSequencer.withActionResultT { currentState =>
newRoundResult(
gs = currentState,
startingState = currentState,
newRoundId = newRoundId,
newDate = newDate
)
@@ -97,31 +103,34 @@ case class NewRoundAction(
// Add stat gain checks (and profession gain for stats that newly cross the threshold)
afterNewRoundSequencer.withRandomActionResults { (latestState, nextRandom) =>
HeroStatGainAction(latestState.heroes.values, latestState.gameId, newDate).randomResultsWithState(nextRandom)
val scalaHeroes = latestState.heroes.values.map(HeroConverter.fromProto)
val scalaDate = DateConverter.fromProto(Some(DateConverter.toProto(newDate)))
HeroStatGainAction(scalaHeroes, latestState.gameId, scalaDate).randomResultsWithState(nextRandom)
}.actionResults
}
private def chronicleLlmRequests(
newDate: Date,
gs: GameState
startingState: GameStateProto
): Vector[LlmRequestT] =
if isChronicleMonth(newDate) then
Vector(
LlmRequestT.ChronicleUpdateMessage(
requestId = s"chronicle_update_${newDate.year}_${newDate.month.value}",
eagleGameId = gs.gameId,
eagleGameId = startingState.gameId,
current_date = newDate,
previous_entries = gs.chronicleEntries.map { entry =>
previous_entries = startingState.chronicleEntries.map { entry =>
ChronicleUpdatePreviousEntry(
date = entry.date,
date = DateConverter.fromProto(entry.date),
generatedTextId = entry.generatedTextId
)
},
}.toVector,
new_entries = ChronicleEventGenerator
.eventTextEntries(
gameHistory = gameHistory,
since = gs.chronicleEntries.lastOption
.map(_.date)
since = startingState.chronicleEntries.lastOption
.flatMap(_.date)
.map(d => DateConverter.fromProto(Some(d)))
.getOrElse(Date(year = 0, month = Date.Month.January))
)
.map(ChronicleEventConverter.fromProto)
@@ -129,11 +138,8 @@ case class NewRoundAction(
)
else Vector()
private def dateBefore(a: Date, b: Date): Boolean =
a.year < b.year || (a.year == b.year && a.month.value < b.month.value)
private def newRoundResult(
gs: GameState,
startingState: GameStateProto,
newRoundId: RoundId,
newDate: Date
): ActionResultT = {
@@ -143,28 +149,29 @@ case class NewRoundAction(
)
val changes: Iterable[Changes] =
gs.provinces.values.map { p =>
startingState.provinces.values.map { p =>
val changedUHs: Vector[UnaffiliatedHeroT] =
p.unaffiliatedHeroes.map { uh =>
uh.copy(
val tUh = UnaffiliatedHeroConverter.fromProto(uh)
tUh.copy(
recruitmentAttempted = false,
roundsInType = uh.roundsInType + 1,
factionBiases = uh.factionBiases.map {
roundsInType = tUh.roundsInType + 1,
factionBiases = tUh.factionBiases.map {
case (fid, v) =>
fid -> modifiedFactionBiasValue(v)
}
)
}
val changedHeroes = changedHeroesAfterOverage(gs, p.id)
}.toVector
val changedHeroes = changedHeroesAfterOverage(startingState, p.id)
// apply caps
val capGoldLoss = (OverResourceLimitLoss.doubleValue * Math.max(
0,
p.gold - ProvinceUtils.goldCap(p)
p.gold - LegacyProvinceUtils.goldCap(p)
)).ceil.toInt
val capFoodLoss = (OverResourceLimitLoss.doubleValue * Math.max(
0,
p.food - ProvinceUtils.foodCap(p)
p.food - LegacyProvinceUtils.foodCap(p)
)).ceil.toInt
val newPriceIndex = PriceIndexUtils.shiftedTowardSteadyState(
@@ -221,7 +228,7 @@ case class NewRoundAction(
val heroesAfterStipend: Map[HeroId, ChangedHeroC] =
changes.flatMap(_.changedHeroes).map(h => h.heroId -> h).toMap
val uppedVigorHeroes: Iterable[ChangedHeroC] = gs.heroes.map {
val uppedVigorHeroes: Iterable[ChangedHeroC] = startingState.heroes.map {
case (hid, h) =>
val baseChange = heroesAfterStipend.getOrElse(hid, ChangedHeroC(heroId = hid))
if h.vigor >= h.constitution then baseChange
@@ -236,9 +243,10 @@ case class NewRoundAction(
val changedHeroes =
uppedVigorHeroes.filter(ch => ch.hasChanges).toVector
val changedFactions = gs.factions.values.map { f =>
val newDateProto = DateConverter.toProto(newDate)
val changedFactions = startingState.factions.values.map { f =>
val removedTruces =
f.factionRelationships.filter(fr => fr.resetDate.exists(dateBefore(_, newDate)))
f.factionRelationships.filter(fr => fr.resetDate.exists(_ < newDateProto))
ChangedFactionC(
factionId = f.id,
@@ -249,15 +257,15 @@ case class NewRoundAction(
resetDate = None,
trustValue = fr.trustValue
)
},
trustLevelUpdates = gs.factions.keys.filterNot(_ == f.id).toVector.map { targetFid =>
}.toVector,
trustLevelUpdates = startingState.factions.keys.filterNot(_ == f.id).toVector.map { targetFid =>
TrustLevelUpdate(targetFid, TrustDeltaPerRound.intValue)
},
clearLastActedProvinceId = true
)
}.toVector
val llmRequests = chronicleLlmRequests(newDate, gs)
val llmRequests = chronicleLlmRequests(newDate, startingState)
ActionResultC(
actionResultType = NewRoundActionResultType,
@@ -278,21 +286,21 @@ case class NewRoundAction(
}
private def changedHeroesAfterOverage(
gs: GameState,
gameState: GameStateProto,
provinceId: ProvinceId
): Vector[ChangedHeroC] = {
val province = gs.provinces(provinceId)
val province = gameState.provinces(provinceId)
val notRecentHeroCount = province.rulingFactionHeroIds
.map(gs.heroes)
.map(gameState.heroes)
.flatMap(_.roundIdJoined)
.count(roundId => gs.currentRoundId - roundId > MinimumRoundsBeforeLoyaltyDegrades.intValue)
.count(roundId => gameState.currentRoundId - roundId > MinimumRoundsBeforeLoyaltyDegrades.intValue)
if province.heroCap >= notRecentHeroCount then Vector.empty
else {
val loyaltyHit =
OverHeroCapLoyaltyDelta.doubleValue * (notRecentHeroCount - province.heroCap)
province.rulingFactionHeroIds
.map(gs.heroes)
.map(gameState.heroes)
.map(h =>
ChangedHeroC(
heroId = h.id,
@@ -301,6 +309,7 @@ case class NewRoundAction(
else StatDelta(loyaltyHit)
)
)
.toVector
}
end if
}
@@ -1,64 +1,94 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.internal.army.MovingArmy
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.province.Province as ProvinceProto
import net.eagle0.eagle.library.actions.applier.{ActionResultTApplier, ActionResultTWithResultingState}
import net.eagle0.eagle.library.actions.impl.common.TRandomSequentialResultsAction
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.{ArmyConverter, SuppliesConverter}
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.MovingArmy
case class PerformProvinceMoveResolutionAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
gameState: GameState
) extends TRandomSequentialResultsAction(gameState) {
override protected def randomResults(
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
actionResultTApplier: ActionResultTApplier
): RandomState[Vector[ActionResultT]] = {
val provincesWithIncomingFriendlies = gameState.provinces.values
val startingState = GameStateConverter.toProto(gameState)
val provincesWithIncomingFriendlies = startingState.provinces.values
.filter(_.rulingFactionId.isDefined)
.flatMap { province =>
val friendlyArmies = province.incomingArmies.filter { ma =>
ma.arrivalRound == gameState.currentRoundId && isFriendlyMove(ma, province)
}
friendlyArmies.map(army => (province.id, army))
.map(p =>
(
p,
p.incomingArmies.filter(ma =>
ma.arrivalRound == startingState.currentRoundId && isFriendlyMove(
ma,
p
)
)
)
)
.filter(_._2.nonEmpty)
val movingFriendliesStates = provincesWithIncomingFriendlies
.foldLeft(Vector(ActionResultTWithResultingState(null, startingState))) {
case (acc, (province, armies)) =>
armies.foldLeft(acc) {
case (acc2, army) =>
acc2 :+ actionResultTApplier.applyActionResult(
acc2.last.resultingState,
FriendlyMoveAction(
ArmyConverter.fromProto(army),
province.id
).immediateExecute
)
}
}
.toVector
val provincesWithIncomingShipments = gameState.provinces.values.flatMap { province =>
val arrivingShipments = province.incomingShipments
.filter(_.arrivalRound == gameState.currentRoundId)
arrivingShipments.map(shipment => (province.id, shipment))
}.toVector
val provincesWithIncomingShipments = startingState.provinces.values
.map(p =>
(
p,
p.incomingShipments
.filter(ma => ma.arrivalRound == startingState.currentRoundId)
)
)
.filter(_._2.nonEmpty)
val sequencer = RandomStateSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
functionalRandom = functionalRandom
)
val states = provincesWithIncomingShipments
.foldLeft(movingFriendliesStates) {
case (acc, (province, shipments)) =>
shipments.foldLeft(acc) {
case (acc2, supplies) =>
acc2 :+ actionResultTApplier.applyActionResult(
acc2.last.resultingState,
ShipmentArrivedAction(
SuppliesConverter.fromProto(supplies),
province.id
).immediateExecute
)
}
}
.drop(1)
val afterFriendlyMoves = provincesWithIncomingFriendlies.foldLeft(sequencer) {
case (seq, (provinceId, movingArmy)) =>
seq.withProtolessSimpleAction(_ => FriendlyMoveAction(movingArmy, provinceId))
}
val afterShipments = provincesWithIncomingShipments.foldLeft(afterFriendlyMoves) {
case (seq, (provinceId, movingSupplies)) =>
seq.withProtolessSimpleAction(_ => ShipmentArrivedAction(movingSupplies, provinceId))
}
afterShipments.withRandomActionResults { (gs, fr) =>
EndProvinceMoveResolutionPhaseAction(
gameId = gs.gameId,
factions = gs.factions.values.toVector,
provinces = gs.provinces.values.toVector,
heroes = gs.heroes.values.toVector
).randomResults(fr)
}.actionResults
val lastState = states.lastOption.map(_.resultingState).getOrElse(startingState)
EndProvinceMoveResolutionPhaseAction(
gameId = lastState.gameId,
factions = lastState.factions.values.map(FactionConverter.fromProto).toVector,
provinces = lastState.provinces.values.toVector.map(ProvinceConverter.fromProto),
heroes = lastState.heroes.values.toVector.map(HeroConverter.fromProto)
).randomResults(functionalRandom)
.map(states.map(_.actionResult) ++ _)
}
private def isFriendlyMove(ma: MovingArmy, province: ProvinceT): Boolean =
province.rulingFactionId.contains(ma.army.factionId)
private def isFriendlyMove(ma: MovingArmy, p: ProvinceProto): Boolean =
ma.army.forall(army => p.rulingFactionId.contains(army.factionId))
}
@@ -1,40 +1,44 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.province.IncomingEndTurnAction as IncomingEndTurnActionProto
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.common.{RandomStateTSequencer, TRandomSequentialResultsAction}
import net.eagle0.eagle.library.util.view_filters.ProvinceViewFilter
import net.eagle0.eagle.library.util.ReturningHeroes
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ClientTextVisibilityExtensionC}
import net.eagle0.eagle.model.action_result.types.{EndReconResolutionPhaseResultType, ReconSucceededResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.proto_converters.IncomingEndTurnActionConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.{IncomingEndTurnAction, IncomingRecon}
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.ProvinceId
case class PerformReconResolutionAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
gameState: GameState
) extends TRandomSequentialResultsAction(gameState) {
private val startingGameState: GameStateProto = GameStateConverter.toProto(gameState)
override protected def randomResults(
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
actionResultTApplier: ActionResultTApplier
): RandomState[Vector[ActionResultT]] =
gameState.provinces.values
startingGameState.provinces.values
.foldLeft(
RandomStateSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
RandomStateTSequencer.fromProto(
initialStateProto = startingGameState,
actionResultApplier = actionResultTApplier,
functionalRandom = functionalRandom
)
) {
case (sequencer, province) =>
province.incomingEndTurnActions.collect {
case action @ IncomingEndTurnAction(_, _, _: IncomingRecon) => action
}
province.incomingEndTurnActions
.filter(_.action.isRecon)
.foldLeft(sequencer) {
case (innerSequencer, action) =>
innerSequencer.withRandomActionResult {
@@ -51,25 +55,26 @@ case class PerformReconResolutionAction(
)
}
// Package-private for testing
private[action] def oneResult(
def oneResult(
provinceId: ProvinceId,
incomingEndTurnAction: IncomingEndTurnAction,
incomingEndTurnAction: IncomingEndTurnActionProto,
functionalRandom: FunctionalRandom
): RandomState[ActionResultT] = {
val originProvince = gameState.provinces(incomingEndTurnAction.fromProvinceId)
val originProvince =
startingGameState.provinces(incomingEndTurnAction.fromProvinceId)
val fromFactionId = incomingEndTurnAction.fromFactionId
val targetProvince = gameState.provinces(provinceId)
val targetProvince = startingGameState.provinces(provinceId)
// Extract heroId from the recon action details
val heroId = incomingEndTurnAction.details.asInstanceOf[IncomingRecon].heroId
val recon = incomingEndTurnAction.getRecon
ReturningHeroes
.heroesReturningToFaction(
hids = Vector(heroId),
hids = Vector(recon.heroId),
factionId = fromFactionId,
originProvince = originProvince,
provinces = gameState.provinces.values.toVector,
originProvince = ProvinceConverter.fromProto(originProvince),
provinces = startingGameState.provinces.values
.map(ProvinceConverter.fromProto)
.toVector,
functionalRandom = functionalRandom
)
.map { returningHeroes =>
@@ -80,25 +85,35 @@ case class PerformReconResolutionAction(
// the CP for the destination province
ChangedProvinceC(
provinceId = provinceId,
removedIncomingEndTurnActions = Vector(incomingEndTurnAction)
removedIncomingEndTurnActions = Vector(
IncomingEndTurnActionConverter.fromProto(incomingEndTurnAction)
)
),
// the CP for the acting hero returning
returningHeroes.changedProvince
),
changedFactions = Vector(
ChangedFactionC(
factionId = fromFactionId,
factionId = incomingEndTurnAction.fromFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter.filteredProvinceView(
gameState.provinces(provinceId),
gameState
)
ProvinceViewFilter
.filteredProvinceView(
targetProvince,
startingGameState
// FIXME: setting factionId to None right now to grab the full info. This
// probably isn't exactly what we want.
)
.withAsOf(startingGameState.currentDate.get)
)
)
),
clientTextVisibilityExtensions = targetProvince.rulingFactionHeroIds.map { hid =>
ClientTextVisibilityExtensionC(
textId = gameState.heroes(hid).backstoryTextId,
textId = startingGameState
.heroes(hid)
.backstoryVersions
.last
.textId,
recipientFactionIds = Vector(fromFactionId)
)
}.toVector
@@ -2,10 +2,9 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{HeroId, ProvinceId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.common.{RandomStateTSequencer, TRandomSequentialResultsAction}
import net.eagle0.eagle.library.actions.name_generation_request.NameGenerationRequestCreator
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.settings.{
FreeHeroMoveVigorCost,
MinVigorForFreeHeroMove,
@@ -27,6 +26,9 @@ import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedHero
import net.eagle0.eagle.model.action_result.types.{HeroChangedResultType, HeroMovedResultType, NewQuestsResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.action_result.NotificationDetails
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.ProvinceT
@@ -103,31 +105,31 @@ object PerformUnaffiliatedHeroesAction {
case class PerformUnaffiliatedHeroesAction(
gameState: GameState,
heroGenerator: HeroGenerator,
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
heroGenerator: HeroGenerator
) extends TRandomSequentialResultsAction(gameState) {
private val currentDate = gameState.currentDate.get
private val factions = gameState.factions.values.toVector
override protected def randomResults(
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
actionResultTApplier: ActionResultTApplier
): RandomState[Vector[ActionResultT]] =
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = actionResultTApplier,
functionalRandom = functionalRandom
).withRandomActionResults((_, fr) => statusChangeResults(fr))
.withRandomActionResults { (gs, fr) =>
heroAppearsResults(gs, fr)
heroAppearsResults(GameStateConverter.fromProto(gs), fr)
}
.withRandomContinuance { (randomSequencer: RandomStateSequencer) =>
.withRandomContinuance { (randomSequencer: RandomStateTSequencer) =>
val newHeroesMap =
randomSequencer.actionResults.newValue
.flatMap(_.newHeroes)
.map(h => h.id -> h)
.toMap
val currentGs = randomSequencer.lastState
val currentGs = GameStateConverter.fromProto(randomSequencer.lastStateProto)
val qcpsOpt = questChangedProvinces(
currentGs = currentGs,
newHeroesMap = newHeroesMap,
@@ -142,14 +144,15 @@ case class PerformUnaffiliatedHeroesAction(
}
randomSequencer.withRandomActionResults((_, _) => qcpsOpt)
}
.withActionResults { gs =>
.withActionResultTs { gs =>
EndUnaffiliatedHeroActionsPhaseAction(
gameId = gs.gameId,
gameId = GameStateConverter.fromProto(gs).gameId,
currentDate = currentDate,
currentRoundId = gs.currentRoundId,
provinces = gs.provinces.values.toVector,
heroes = gs.heroes.values.toVector,
factions = gs.factions.values.toVector
provinces = gs.provinces.values.map(ProvinceConverter.fromProto).toVector,
heroes = gs.heroes.values.map(HeroConverter.fromProto).toVector,
factions =
gs.factions.values.map(net.eagle0.eagle.model.proto_converters.faction.FactionConverter.fromProto).toVector
).results
}
.actionResults
@@ -1,6 +1,6 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.eagle.{BattalionId, FactionId, GameId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -13,21 +13,19 @@ import net.eagle0.eagle.model.action_result.types.{
}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.{HostileArmyGroup, RoundPhase}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.date.Date as DateT
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
case class PerformUncontestedConquestAction(
gameId: GameId,
currentRoundId: RoundId,
currentDate: DateT,
provinces: Map[ProvinceId, ProvinceT],
factions: Map[FactionId, FactionT],
heroes: Map[HeroId, HeroT],
battalions: Map[BattalionId, BattalionT]
gameState: GameState
) extends ProtolessSequentialResultsAction {
private val gameId = gameState.gameId
private val currentRoundId = gameState.currentRoundId
private val currentDate = gameState.currentDate.get
private val provinces = gameState.provinces
private val factions = gameState.factions
private val heroes = gameState.heroes
private val battalions = gameState.battalions
private val endPhaseAction: ActionResultT = ActionResultC(
actionResultType = EndUncontestedConquestPhaseResultType,
@@ -6,107 +6,89 @@ import net.eagle0.eagle.api.available_command.{AvailableCommand, RestAvailableCo
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType.*
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.command.TCommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.command.CommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, RandomStateTSequencer}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
case class PerformVassalCommandsPhaseAction(
gameState: GameState,
commandsForProvince: ProvinceId => Option[OneProvinceAvailableCommands],
commandFactory: TCommandFactory,
actionResultApplier: ActionResultApplier
commandFactory: CommandFactory,
applier: ActionResultTApplier
) extends ProtolessRandomSequentialResultsAction {
private val gameStateProto: GameStateProto = GameStateConverter.toProto(gameState)
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
private def vassalProvinces(gs: GameState): Vector[ProvinceT] =
gs.provinces.values
.filter(_.rulingFactionId.isDefined)
.filterNot(ProvinceUtils.ruledByFactionLeader(_, gs.factions.values.toVector))
.filterNot(_.hasActed)
.toVector
override def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] =
vassalProvinces(gameState)
gameStateProto.provinces.values
.filter(_.rulingFactionId.isDefined)
.filterNot(LegacyProvinceUtils.ruledByFactionLeader(_, gameStateProto))
.filterNot(_.hasActed)
.foldLeft(
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = applier,
functionalRandom = functionalRandom
)
) {
case (sequencer, province) =>
commandsForProvince(province.id).map { opac =>
sequencer.withRandomActionResults { (gs, fr) =>
chooseCommand(gs, province.id, fr).continue {
case (Some(cs), nextFr) =>
val cmd = commandFactory.makeTCommand(
actingFactionId = cs.actingFactionId,
gameState = gs,
availableCommand = cs.available,
selectedCommand = cs.selected
)
cmd match {
case TCommand.Simple(action) =>
RandomState(Vector(action.immediateExecute), nextFr)
case TCommand.RandomSimple(action) =>
action.immediateExecute(nextFr).map(ar => Vector(ar))
case TCommand.Sequential(action) =>
RandomState(action.results, nextFr)
}
case (None, nextFr) =>
RandomState(Vector.empty[ActionResultT], nextFr)
sequencer.withOptionalRandomTCommand { (gs, fr) =>
chooseCommand(province.id, fr).map { maybeCS =>
maybeCS.map { cs =>
commandFactory.makeTCommand(
actingFactionId = cs.actingFactionId,
gameState = GameStateConverter.fromProto(gs),
availableCommand = cs.available,
selectedCommand = cs.selected
)
}
}
}
.getOrElse(sequencer)
}
.actionResults
private def maybeRestCommand(
actingFactionId: FactionId,
gsProto: GameStateProto,
gs: GameStateProto,
commandOptions: Vector[AvailableCommand],
provinceId: ProvinceId,
reason: String
): Option[CommandSelection] = {
val heroes =
gsProto
.provinces(provinceId)
gs.provinces(provinceId)
.rulingFactionHeroIds
.map(gsProto.heroes)
.map(gs.heroes)
MoreOption.flatWhen(
commandOptions.collectFirst {
case ac: RestAvailableCommand =>
ac
}.isDefined && shouldRest(heroes)
) {
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
chosenRestCommand(actingFactionId, gs, commandOptions, reason)
}
}
private def selectedCommandFromOrders(
actingFactionId: FactionId,
gsProto: GameStateProto,
gs: GameStateProto,
commandOptions: Vector[AvailableCommand],
functionalRandom: FunctionalRandom,
provinceId: ProvinceId
): RandomState[Option[CommandSelection]] =
gsProto.provinces(provinceId).provinceOrders match {
gs.provinces(provinceId).provinceOrders match {
case ENTRUST =>
chosenEntrustCommand(
actingFactionId = actingFactionId,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions,
actingProvinceId = provinceId,
functionalRandom = functionalRandom
@@ -114,21 +96,21 @@ case class PerformVassalCommandsPhaseAction(
case DEVELOP =>
chosenDevelopCommand(
actingFactionId = actingFactionId,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions,
functionalRandom = functionalRandom
)
case MOBILIZE =>
chosenMobilizeCommand(
actingFactionId = actingFactionId,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions,
functionalRandom = functionalRandom
)
case EXPAND =>
chosenExpandCommand(
actingFactionId,
gsProto,
gs,
commandOptions,
functionalRandom
)
@@ -137,7 +119,7 @@ case class PerformVassalCommandsPhaseAction(
RandomState(
chosenRestCommand(
actingFactionId,
gsProto,
gs,
commandOptions,
"bad orders"
),
@@ -146,17 +128,13 @@ case class PerformVassalCommandsPhaseAction(
}
def chooseCommand(
gs: GameState,
provinceId: ProvinceId,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
// Convert to proto for CommandChoiceHelpers which expects proto GameState
val gsProto = GameStateConverter.toProto(gs)
): RandomState[Option[CommandSelection]] =
commandsForProvince(provinceId).map { oneProvinceAvailableCommands =>
CommandChooser.choose(
gs.provinces(provinceId).rulingFactionId.get,
gsProto,
gameStateProto.provinces(provinceId).getRulingFactionId,
gameStateProto,
oneProvinceAvailableCommands.commands.toVector,
Vector[CommandChooser](
resolveTributeSelectedCommand,
@@ -164,14 +142,14 @@ case class PerformVassalCommandsPhaseAction(
handleRiotSelectedCommand,
(
fid: FactionId,
gsP: GameStateProto,
gs: GameStateProto,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
RandomState(
maybeRestCommand(
fid,
gsP,
gs,
acs,
provinceId,
"chosen vassal command: rest"
@@ -180,13 +158,12 @@ case class PerformVassalCommandsPhaseAction(
),
(
fid: FactionId,
gsP: GameStateProto,
gs: GameStateProto,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) => selectedCommandFromOrders(fid, gsP, acs, fr, provinceId)
) => selectedCommandFromOrders(fid, gs, acs, fr, provinceId)
),
functionalRandom
)
}.get
}
}
@@ -2,94 +2,79 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.command.TCommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.command.CommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, RandomStateTSequencer}
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.ProvinceId
case class PerformVassalDefenseDecisionsAction(
gameState: GameState,
commandsForProvince: ProvinceId => Option[OneProvinceAvailableCommands],
commandFactory: TCommandFactory,
actionResultApplier: ActionResultApplier
commandFactory: CommandFactory,
applier: ActionResultTApplier
) extends ProtolessRandomSequentialResultsAction {
private val gameStateProto: GameStateProto = GameStateConverter.toProto(gameState)
private def vassalProvinces(gs: GameState): Vector[ProvinceT] =
gs.provinces.values
.filter(_.rulingFactionId.isDefined)
.filterNot(ProvinceUtils.ruledByFactionLeader(_, gs.factions.values.toVector))
.toVector
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
override def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] =
vassalProvinces(gameState)
gameStateProto.provinces.values
.filter(_.rulingFactionId.isDefined)
.filterNot(LegacyProvinceUtils.ruledByFactionLeader(_, gameStateProto))
.toVector
.foldLeft(
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = applier,
functionalRandom = functionalRandom
)
) {
case (sequencer, province) =>
commandsForProvince(province.id).map { opac =>
sequencer.withRandomActionResults { (gs, fr) =>
chooseCommand(gs, province.id, opac.commands.toVector, fr).continue {
case (Some(cs), nextFr) =>
val cmd = commandFactory.makeTCommand(
actingFactionId = cs.actingFactionId,
gameState = gs,
availableCommand = cs.available,
selectedCommand = cs.selected
)
cmd match {
case TCommand.Simple(action) =>
RandomState(Vector(action.immediateExecute), nextFr)
case TCommand.RandomSimple(action) =>
action.immediateExecute(nextFr).map(ar => Vector(ar))
case TCommand.Sequential(action) =>
RandomState(action.results, nextFr)
}
case (None, nextFr) =>
RandomState(Vector.empty[ActionResultT], nextFr)
sequencer.withOptionalRandomTCommand { (gs, fr) =>
chooseCommand(province.id, fr).map { maybeCS =>
maybeCS.map { cs =>
commandFactory.makeTCommand(
actingFactionId = cs.actingFactionId,
gameState = GameStateConverter.fromProto(gs),
availableCommand = cs.available,
selectedCommand = cs.selected
)
}
}
}
.getOrElse(sequencer)
}
.actionResults
private def chooseCommand(
gs: GameState,
def chooseCommand(
provinceId: ProvinceId,
commandOptions: Seq[net.eagle0.eagle.api.available_command.AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
// Convert to proto for CommandChoiceHelpers which expects proto GameState
val gsProto = GameStateConverter.toProto(gs)
): RandomState[Option[CommandSelection]] =
commandsForProvince(provinceId).map { oneProvinceAvailableCommands =>
val commandOptions = oneProvinceAvailableCommands.commands
CommandChoiceHelpers.resolveTributeSelectedCommand(
actingFactionId = gs.provinces(provinceId).rulingFactionId.get,
gameState = gsProto,
availableCommands = commandOptions.toVector,
functionalRandom = functionalRandom
) match {
case rss @ RandomState(Some(_), _) => rss
case RandomState(None, fr) =>
CommandChoiceHelpers.defendSelectedCommand(
gs.provinces(provinceId).rulingFactionId.get,
gsProto,
commandOptions.toVector,
fr
)
resolveTributeSelectedCommand(
actingFactionId = gameStateProto.provinces(provinceId).getRulingFactionId,
gameState = gameStateProto,
availableCommands = commandOptions.toVector,
functionalRandom = functionalRandom
) match {
case rss @ RandomState(Some(_), _) => rss
case RandomState(None, fr) =>
defendSelectedCommand(
gameStateProto.provinces(provinceId).getRulingFactionId,
gameStateProto,
commandOptions.toVector,
fr
)
}
}
}
.getOrElse(RandomState(None, functionalRandom))
}
@@ -2,7 +2,7 @@ package net.eagle0.eagle.library.actions.impl.action
import scala.util.hashing.MurmurHash3
import net.eagle0.eagle.{BattalionId, FactionId, GameId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId, RoundId}
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.util.BattalionUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -10,10 +10,8 @@ import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedPro
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.{SentSuppliesResultType, StartBattleResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.{Army, BattalionType, BattalionTypeId, HostileArmyGroup, MovingArmy, Supplies}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.{Army, BattalionType, HostileArmyGroup, MovingArmy, Supplies}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.shardok_battle.{BattleType, ShardokBattle, ShardokPlayer, VictoryCondition}
import net.eagle0.eagle.model.state.unit_status.UnitStatus
@@ -21,16 +19,17 @@ import net.eagle0.eagle.model.state.HostileArmyGroupStatus.Attacking
import net.eagle0.eagle.shardok_interface.ResolvedEagleUnit
case class RequestBattlesAction(
gameId: GameId,
currentRoundId: RoundId,
currentDate: net.eagle0.eagle.model.state.date.Date,
battleCounter: Int,
heroes: Map[HeroId, HeroT],
battalions: Map[BattalionId, BattalionT],
provinces: Map[ProvinceId, ProvinceT],
factions: Map[FactionId, FactionT],
battalionTypes: Map[BattalionTypeId, BattalionType]
gameState: GameState
) extends ProtolessSequentialResultsAction {
private val gameId = gameState.gameId
private val currentRoundId = gameState.currentRoundId
private val currentDate = gameState.currentDate.get
private val battleCounter = gameState.battleCounter
private val heroes = gameState.heroes
private val battalions = gameState.battalions
private val provinces = gameState.provinces
private val factions = gameState.factions
private val battalionTypes = gameState.battalionTypes.map(bt => bt.typeId -> bt).toMap
override def results: Vector[ActionResultT] =
provincesWithAttackingArmies.zipWithIndex.flatMap { case (pwaas, idx) => handleOneProvince(pwaas, idx) }
@@ -2,24 +2,21 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.actions.applier.ActionResultTApplier
import net.eagle0.eagle.library.actions.impl.common.{RandomStateTSequencer, TRandomSequentialResultsAction}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.action_result.changed_province.concrete.{ChangedProvinceC, HostileArmyStatusChange}
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.{EndTruceTurnBackPhaseResultType, WithdrawalForTruceResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.HostileArmyGroupStatus
import net.eagle0.eagle.model.state.RoundPhase
case class TruceTurnBackPhaseAction(
gameState: GameState,
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
case class TruceTurnBackPhaseAction(gameState: GameState) extends TRandomSequentialResultsAction(gameState) {
private val factions: Vector[FactionT] = gameState.factions.values.toVector
private def checkOneAttackingArmyGroup(
@@ -63,21 +60,22 @@ case class TruceTurnBackPhaseAction(
.getOrElse(Vector())
override protected def randomResults(
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
actionResultTApplier: ActionResultTApplier
): RandomState[Vector[ActionResultT]] =
RandomStateSequencer(
RandomStateTSequencer(
initialState = gameState,
actionResultApplier = actionResultApplier,
actionResultApplier = actionResultTApplier,
functionalRandom = functionalRandom
)
.withActionResults(_ => gameState.provinces.values.toVector.flatMap(checkOneProvince))
.withActionResults(gs =>
.withProtolessSequentialResultsAction(gs =>
WithdrawnArmiesReturnHomeAction(
gs.currentRoundId,
gs.provinces.values.toVector
).results
gs.provinces.values.map(ProvinceConverter.fromProto).toVector
)
)
.withActionResult(_ =>
.withActionResultT(_ =>
ActionResultC(
actionResultType = EndTruceTurnBackPhaseResultType,
newRoundPhase = Some(RoundPhase.BattleRequest)
@@ -0,0 +1,84 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.common.action_result_notification_details.{Notification, OutlawSpottedDetails}
import net.eagle0.eagle.common.action_result_type.ActionResultType.HERO_MOVED
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.UNAFFILIATED_HERO_OUTLAW
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.changed_hero.ChangedHero
import net.eagle0.eagle.internal.changed_hero.ChangedHero.Vigor
import net.eagle0.eagle.internal.changed_province.ChangedProvince
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.settings.FreeHeroMoveVigorCost
import net.eagle0.eagle.library.util.unaffiliated_hero.UnaffiliatedHeroUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.UnaffiliatedHeroConverter
import net.eagle0.eagle.ProvinceId
case class UnaffiliatedHeroMovedAction(
uh: UnaffiliatedHero,
fromProvinceId: ProvinceId,
toProvinceId: ProvinceId,
gameState: GameState
) {
private val affectedProvinces =
Vector(fromProvinceId, toProvinceId).map(gameState.provinces)
private val affectedFactions = affectedProvinces.flatMap(_.rulingFactionId)
private val notifications = Option
.when(uh.`type` == UNAFFILIATED_HERO_OUTLAW && affectedFactions.nonEmpty) {
Notification(
details = OutlawSpottedDetails(
outlawHeroId = uh.heroId,
provinceId = toProvinceId
),
targetFactions = affectedFactions
.map(fid => Notification.TargetFaction(factionId = fid))
)
}
.toVector
def immediateExecute(
functionalRandom: FunctionalRandom
): RandomState[ActionResult] =
computeNewRecruitmentInfo(functionalRandom).map { newRecruitmentInfo =>
ActionResult(
`type` = HERO_MOVED,
changedHeroes = Vector(
ChangedHero(
id = uh.heroId,
vigor = Vigor.VigorDelta(-FreeHeroMoveVigorCost.doubleValue)
)
),
changedProvinces = Vector(
ChangedProvince(
id = fromProvinceId,
removedUnaffiliatedHeroIds = Vector(uh.heroId)
),
ChangedProvince(
id = toProvinceId,
newUnaffiliatedHeroes = Vector(
uh.update(
_.recruitmentInfo := UnaffiliatedHeroConverter.recruitmentInfoToProto(newRecruitmentInfo)
)
)
)
),
notificationsToDeliver = notifications
)
}
private def computeNewRecruitmentInfo(
functionalRandom: FunctionalRandom
): RandomState[net.eagle0.eagle.model.state.unaffiliated_hero.RecruitmentInfo] = {
val scalaGameState = GameStateConverter.fromProto(gameState)
UnaffiliatedHeroUtils.newRecruitmentInfo(
gameState = scalaGameState,
provinceId = toProvinceId,
unaffiliatedHero = UnaffiliatedHeroConverter.fromProto(uh),
hero = scalaGameState.heroes(uh.heroId),
functionalRandom = functionalRandom
)
}
}
@@ -1,21 +1,23 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "t_command_factory",
srcs = ["TCommandFactory.scala"],
name = "command_base",
srcs = ["Command.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_command",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_command",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
#"@maven//:com_thesamet_scalapb_lenses_3",
],
)
@@ -27,13 +29,14 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
":t_command_factory",
":command_base",
],
deps = [
":alms_command",
":apprehend_outlaw_command",
":arm_troops_command",
":attack_decision_command",
":command_base",
":control_weather_command",
":decline_quest_command",
":defend_command",
@@ -53,6 +56,9 @@ scala_library(
":march_command",
":organize_troops_command",
":please_recruit_me_command",
":protoless_random_simple_action_wrapper",
":protoless_sequential_results_action_wrapper",
":protoless_simple_action_wrapper",
":recon_command",
":recruit_heroes_command",
":resolve_alliance_offer_command",
@@ -67,7 +73,6 @@ scala_library(
":start_epidemic_command",
":suppress_beasts_command",
":swear_brotherhood_command",
":t_command_factory",
":trade_command",
":train_command",
":travel_command",
@@ -908,6 +913,98 @@ scala_library(
],
)
scala_library(
name = "protoless_random_simple_action_wrapper",
srcs = ["ProtolessRandomSimpleActionWrapper.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
deps = [
":command_base",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_simple_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_proto_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:vigor_xp_applier",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
scala_library(
name = "protoless_sequential_results_action_wrapper",
srcs = ["ProtolessSequentialResultsActionWrapper.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
deps = [
":command_base",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:random_state_trait_sequencer",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:vigor_xp_applier",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_runtime_validator",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//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:gender",
],
)
scala_library(
name = "protoless_simple_action_wrapper",
srcs = ["ProtolessSimpleActionWrapper.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":command_base",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_simple_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:vigor_xp_applier",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
scala_library(
name = "alms_command",
srcs = ["AlmsCommand.scala"],
@@ -0,0 +1,40 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.changed_faction.ChangedFaction
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.library.actions.impl.common.{Action, ActionWithResultingState}
import net.eagle0.eagle.FactionId
object Command {
private def newChangedFaction(
result: ActionResult,
factionId: Option[FactionId]
): Option[ChangedFaction] =
for {
pid <- result.province
fid <- factionId
} yield ChangedFaction(
id = fid,
newLastActedProvinceId = Some(pid)
)
def resultWithLastCommand(
result: ActionResult,
selectedCommand: SelectedCommand,
factionId: Option[FactionId]
): ActionResult =
result.update(
_.lastCommandTypeForActingProvince := result.province
.map(_ => selectedCommand)
.getOrElse(SelectedCommand.Empty),
_.changedFactions :++= newChangedFaction(result, factionId)
)
}
trait Command extends Action {
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState]
}
@@ -66,7 +66,7 @@ import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.BattalionTypeId
class CommandFactory extends TCommandFactory {
class CommandFactory {
private def allProvinces(gameState: GameState): Vector[ProvinceT] =
gameState.provinces.values.toVector
@@ -114,6 +114,34 @@ class CommandFactory extends TCommandFactory {
previousBackstoryTextIdLookup = hid => gameState.heroes(hid).backstoryTextId
)
def makeCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommand: AvailableCommand,
selectedCommand: SelectedCommand
): Command =
makeCommandInternal(actingFactionId, gameState, availableCommand, selectedCommand) match {
case protolessSimpleAction: ProtolessSimpleAction =>
new ProtolessSimpleActionWrapper(
startingState = gameState,
protolessSimpleAction = protolessSimpleAction,
selectedCommand = selectedCommand
)
case protolessRandomSimpleAction: ProtolessRandomSimpleAction =>
new ProtolessRandomSimpleActionWrapper(
startingState = gameState,
protolessRandomSimpleAction = protolessRandomSimpleAction,
selectedCommand = selectedCommand
)
case protolessSequentialResultsAction: ProtolessSequentialResultsAction =>
new ProtolessSequentialResultsActionWrapper(
startingState = gameState,
protolessSequentialResultsAction = protolessSequentialResultsAction
)
case c: Command => c
}
/**
* Creates a T-type command action directly, without proto wrapping.
*
@@ -133,6 +161,10 @@ class CommandFactory extends TCommandFactory {
TCommand.RandomSimple(protolessRandomSimpleAction)
case protolessSequentialResultsAction: ProtolessSequentialResultsAction =>
TCommand.Sequential(protolessSequentialResultsAction)
case _: Command =>
throw new EagleInternalException(
"makeTCommand called on a command type that is not yet converted to T-types"
)
}
private def makeCommandInternal(
@@ -140,7 +172,7 @@ class CommandFactory extends TCommandFactory {
gameState: GameState,
availableCommand: AvailableCommand,
selectedCommand: SelectedCommand
): ProtolessSimpleAction | ProtolessRandomSimpleAction | ProtolessSequentialResultsAction =
): ProtolessSimpleAction | ProtolessRandomSimpleAction | ProtolessSequentialResultsAction | Command =
(availableCommand, selectedCommand) match {
case (
aoac: ApprehendOutlawAvailableCommand,
@@ -0,0 +1,54 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.common.{RandomState, SeededRandom}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.common.action_result_type.ActionResultType.NEW_RANDOM_SEED
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.library.actions.impl.common.{
ActionWithResultingState,
ProtolessRandomSimpleAction,
RandomStateProtoSequencer,
VigorXPApplier
}
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
class ProtolessRandomSimpleActionWrapper(
startingState: GameState,
protolessRandomSimpleAction: ProtolessRandomSimpleAction,
selectedCommand: SelectedCommand
) extends Command {
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState] =
RandomStateProtoSequencer(
initialState = startingState,
actionResultProtoApplier = actionResultProtoApplier,
functionalRandom = SeededRandom(startingState.randomSeed)
).withRandomActionResult {
case (gs, fr) =>
protolessRandomSimpleAction
.immediateExecute(fr)
.map(ar => VigorXPApplier.withVigorXp(ActionResultProtoConverter.toProto(ar)))
.map { resultWithVigorXp =>
Command.resultWithLastCommand(
result = resultWithVigorXp,
selectedCommand = selectedCommand,
factionId = resultWithVigorXp.province
.map(startingState.provinces)
.flatMap(_.rulingFactionId)
)
}
}.withRandomActionResult {
case (gs, fr) =>
RandomState(
ActionResult(
`type` = NEW_RANDOM_SEED,
newRandomSeed = Some(fr.seed)
),
fr
)
}.results.newValue
}
@@ -0,0 +1,38 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.common.SeededRandom
import net.eagle0.eagle.library.actions.applier.{ActionResultProtoApplier, ActionResultTApplierImpl}
import net.eagle0.eagle.library.actions.impl.common.{
ActionWithResultingState,
ProtolessSequentialResultsAction,
RandomStateTSequencer,
VigorXPApplier
}
import net.eagle0.eagle.library.util.validations.ScalaRuntimeValidator
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.game_state.GameState
class ProtolessSequentialResultsActionWrapper(
startingState: GameState,
protolessSequentialResultsAction: ProtolessSequentialResultsAction
) extends Command {
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState] = {
val arts = protolessSequentialResultsAction.results match {
case items if items.isEmpty =>
throw new EagleInternalException(
"ProtolessSequentialResultsActionWrapper must have at least one result"
)
case h +: t =>
VigorXPApplier.withVigorXp(h) +: t
case _ => ??? // above cases should cover
}
RandomStateTSequencer(
initialState = startingState,
actionResultApplier = ActionResultTApplierImpl(ScalaRuntimeValidator),
functionalRandom = SeededRandom(startingState.randomSeed)
).withActionResultTs(_ => arts).actionsWithResultingStates.newValue
}
}
@@ -0,0 +1,36 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.library.actions.impl.common.{ActionWithResultingState, ProtolessSimpleAction, VigorXPApplier}
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
class ProtolessSimpleActionWrapper(
startingState: GameState,
protolessSimpleAction: ProtolessSimpleAction,
selectedCommand: SelectedCommand
) extends Command {
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState] =
VigorXPApplier.withVigorXp(
ActionResultProtoConverter.toProto(protolessSimpleAction.immediateExecute)
) match {
case result =>
Vector(
actionResultProtoApplier.applyActionResult(
startingState = GameStateConverter.toProto(startingState),
result = Command.resultWithLastCommand(
result = result,
selectedCommand = selectedCommand,
factionId = result.province
.map(startingState.provinces)
.flatMap(_.rulingFactionId)
)
)
)
}
}
@@ -0,0 +1,51 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.common.{FunctionalRandom, RandomState, SeededRandom}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.common.action_result_type.ActionResultType.NEW_RANDOM_SEED
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.library.actions.impl.common.{
ActionWithResultingState,
RandomStateProtoSequencer,
VigorXPApplier
}
abstract class RandomSingleResultCommand(
startingState: GameState,
val selectedCommand: SelectedCommand
) extends Command {
def immediateExecute(
functionalRandom: FunctionalRandom
): RandomState[ActionResult]
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState] =
RandomStateProtoSequencer(
initialStateProto = startingState,
actionResultProtoApplier = actionResultProtoApplier,
functionalRandom = SeededRandom(startingState.randomSeed)
).withRandomActionResult {
case (gs, fr) =>
immediateExecute(fr).map(VigorXPApplier.withVigorXp).map { resultWithVigorXp =>
Command.resultWithLastCommand(
result = resultWithVigorXp,
selectedCommand = selectedCommand,
factionId = resultWithVigorXp.province
.map(startingState.provinces)
.flatMap(_.rulingFactionId)
)
}
}.withRandomActionResult {
case (gs, fr) =>
RandomState(
ActionResult(
`type` = NEW_RANDOM_SEED,
newRandomSeed = Some(fr.seed)
),
fr
)
}.results.newValue
}
@@ -119,8 +119,8 @@ object ResolveRansomOfferCommand {
case Accepted =>
NotificationDetails.RansomPaid(
ransomedHeroId = ransomOffer.prisonerToBeRansomed.prisonerHeroId,
ransomPaidByFactionId = ransomOffer.originatingFactionId,
ransomPaidToFactionId = actingFactionId
ransomPaidByFactionId = actingFactionId,
ransomPaidToFactionId = ransomOffer.originatingFactionId
)
case Rejected =>
@@ -0,0 +1,31 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.library.actions.impl.common.{ActionWithResultingState, SimpleAction, VigorXPApplier}
class SimpleActionWrapper(
startingState: GameState,
simpleAction: SimpleAction,
selectedCommand: SelectedCommand
) extends Command {
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState] =
VigorXPApplier.withVigorXp(simpleAction.immediateExecute) match {
case result =>
Vector(
actionResultProtoApplier.applyActionResult(
startingState = startingState,
result = Command.resultWithLastCommand(
result = result,
selectedCommand = selectedCommand,
factionId = result.province
.map(startingState.provinces)
.flatMap(_.rulingFactionId)
)
)
)
}
}
@@ -1,17 +0,0 @@
package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.actions.impl.common.TCommand
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
/** Trait for creating T-type command actions. Extracted from CommandFactory to allow lightweight mocking in tests. */
trait TCommandFactory {
def makeTCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommand: AvailableCommand,
selectedCommand: SelectedCommand
): TCommand
}
@@ -33,6 +33,28 @@ scala_library(
],
)
scala_library(
name = "deterministic_sequential_results_action",
srcs = ["DeterministicSequentialResultsAction.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
exports = [
":action",
":action_with_resulting_state",
],
deps = [
":action",
":action_with_resulting_state",
":vigor_xp_applier",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier_impl",
],
)
scala_library(
name = "t_random_sequential_results_action",
srcs = ["TRandomSequentialResultsAction.scala"],
@@ -78,7 +100,6 @@ scala_library(
":action_with_resulting_state",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
],
deps = [
":action_with_resulting_state",
@@ -178,6 +199,80 @@ scala_library(
],
)
scala_library(
name = "random_state_proto_sequencer",
srcs = ["RandomStateProtoSequencer.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":action",
":action_with_resulting_state",
":protoless_sequential_results_action",
":protoless_simple_action",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier_impl",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
scala_library(
name = "random_state_trait_sequencer",
srcs = ["RandomStateTSequencer.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
exports = [
":t_command",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":action",
":action_with_resulting_state",
":protoless_random_simple_action",
":protoless_sequential_results_action",
":protoless_simple_action",
":t_command",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_proto_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
scala_library(
name = "vigor_xp_applier",
srcs = ["VigorXPApplier.scala"],
@@ -0,0 +1,32 @@
package net.eagle0.eagle.library.actions.impl.common
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.library.actions.impl.common
abstract class DeterministicSequentialResultsAction(startingState: GameState) extends Action {
def results(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionResult]
override def execute(
actionResultProtoApplier: ActionResultProtoApplier
): Vector[ActionWithResultingState] =
if results(actionResultProtoApplier).isEmpty then Vector.empty
else
results(actionResultProtoApplier)
.map(VigorXPApplier.withVigorXp)
.foldLeft(
Vector(
common.ActionWithResultingState(
gameState = startingState,
actionResult = null
)
)
)((aws, far) =>
aws :+ actionResultProtoApplier
.applyActionResult(aws.last.gameState, far)
)
.drop(1)
}
@@ -27,7 +27,7 @@ abstract class ProtolessRandomSequentialResultsAction {
}
/**
* Execute this action with an explicit starting state and convert results to proto format.
* Execute this action and convert results to proto format.
*
* This is the bridge between T-type actions and the proto-based engine interface.
*/
@@ -0,0 +1,286 @@
package net.eagle0.eagle.library.actions.impl.common
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.internal.action_result.ActionResult as ActionResultProto
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultProtoApplier
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.state.game_state.GameState
trait RandomStateProtoSequencer {
def lastStateProto: GameStateProto
def results: RandomState[Vector[ActionWithResultingState]]
def actionResults: RandomState[Vector[ActionResultProto]]
def withRandomActionResult(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[ActionResultProto]
): RandomStateProtoSequencer
def withRandomActionResults(
actionResultsGen: (
GameStateProto,
FunctionalRandom
) => RandomState[Vector[ActionResultProto]]
): RandomStateProtoSequencer
def withRandomAction(
actionGen: (GameStateProto, FunctionalRandom) => RandomState[Action]
): RandomStateProtoSequencer
// ActionResultSequencer passthroughs
def withAction(action: GameStateProto => Action): RandomStateProtoSequencer
def withProtolessSimpleAction(
action: GameStateProto => ProtolessSimpleAction
): RandomStateProtoSequencer
def withProtolessSequentialResultsAction(
action: GameStateProto => ProtolessSequentialResultsAction
): RandomStateProtoSequencer
def withRandomActionResultT(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[ActionResultT]
): RandomStateProtoSequencer
def withRandomActionResultTs(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[Vector[ActionResultT]]
): RandomStateProtoSequencer
def withActionResult(
actionResultGen: GameStateProto => ActionResultProto
): RandomStateProtoSequencer
def withActionResultT(
actionResultGen: GameStateProto => ActionResultT
): RandomStateProtoSequencer
def withActionResultTs(
actionResultGen: GameStateProto => Iterable[ActionResultT]
): RandomStateProtoSequencer
def withActionResults(
actionResultsGen: GameStateProto => Iterable[ActionResultProto]
): RandomStateProtoSequencer
def withActionWithResultingState(
awrsGen: GameStateProto => ActionWithResultingState
): RandomStateProtoSequencer
def withContinuance(
continuance: RandomStateProtoSequencer => RandomStateProtoSequencer
): RandomStateProtoSequencer
def withRandomContinuance(
randomContinuance: RandomStateProtoSequencer => RandomStateProtoSequencer
): RandomStateProtoSequencer
def foldIn[T](ts: Iterable[T])(
f: (T, GameStateProto, FunctionalRandom) => RandomState[ActionResultProto]
): RandomStateProtoSequencer
}
object RandomStateProtoSequencer {
def apply(
initialState: GameState,
actionResultProtoApplier: ActionResultProtoApplier,
functionalRandom: FunctionalRandom
): RandomStateProtoSequencer =
apply(
initialStateProto = GameStateConverter.toProto(initialState),
actionResultProtoApplier = actionResultProtoApplier,
functionalRandom = functionalRandom
)
def apply(
initialStateProto: GameStateProto,
actionResultProtoApplier: ActionResultProtoApplier,
functionalRandom: FunctionalRandom
): RandomStateProtoSequencer =
RandomStateProtoSequencerImpl(
initialStateProto = initialStateProto,
actionResultProtoApplier = actionResultProtoApplier,
results = RandomState(
Vector(),
functionalRandom
)
)
}
private case class RandomStateProtoSequencerImpl(
initialStateProto: GameStateProto,
actionResultProtoApplier: ActionResultProtoApplier,
results: RandomState[Vector[ActionWithResultingState]]
) extends RandomStateProtoSequencer {
override def actionResults: RandomState[Vector[ActionResultProto]] =
results.map(_.map(_.actionResult))
override val lastStateProto: GameStateProto =
results.newValue.lastOption.map(_.gameState).getOrElse(initialStateProto)
override def withAction(
action: GameStateProto => Action
): RandomStateProtoSequencer =
copy(
results = results.map(
_ ++ action(lastStateProto).execute(actionResultProtoApplier)
)
)
override def withProtolessSimpleAction(
action: GameStateProto => ProtolessSimpleAction
): RandomStateProtoSequencer =
withActionResultT(gs => action(gs).immediateExecute)
override def withProtolessSequentialResultsAction(
action: GameStateProto => ProtolessSequentialResultsAction
): RandomStateProtoSequencer =
withActionResults(gs => action(gs).results.map(ActionResultProtoConverter.toProto))
override def withActionResultT(
action: GameStateProto => ActionResultT
): RandomStateProtoSequencer =
withActionResult(gs =>
ActionResultProtoConverter.toProto(
action(gs)
)
)
override def withActionResultTs(
action: GameStateProto => Iterable[ActionResultT]
): RandomStateProtoSequencer =
withActionResults(gs => action(gs).map(ActionResultProtoConverter.toProto))
override def withActionResult(
actionResultGen: GameStateProto => ActionResultProto
): RandomStateProtoSequencer =
copy(
results = results.map(
_ :+ actionResultProtoApplier
.applyActionResult(lastStateProto, actionResultGen(lastStateProto))
)
)
override def withActionResults(
actionResultsGen: GameStateProto => Iterable[ActionResultProto]
): RandomStateProtoSequencer =
copy(
results = results.map(
_ ++ actionResultProtoApplier
.applyActionResults(lastStateProto, actionResultsGen(lastStateProto))
)
)
override def foldIn[T](ts: Iterable[T])(
f: (T, GameStateProto, FunctionalRandom) => RandomState[ActionResultProto]
): RandomStateProtoSequencer =
ts.foldLeft(this) {
case (sequencer, t) =>
sequencer.withRandomActionResult { case (gs, fr) => f(t, gs, fr) }
}
override def withActionWithResultingState(
awrsGen: GameStateProto => ActionWithResultingState
): RandomStateProtoSequencer = copy(
results = results.map(_ :+ awrsGen(lastStateProto))
)
override def withContinuance(
continuance: RandomStateProtoSequencer => RandomStateProtoSequencer
): RandomStateProtoSequencer = continuance(this)
override def withRandomActionResult(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[ActionResultProto]
): RandomStateProtoSequencerImpl =
results match {
case RandomState(ars, fr) =>
copy(
results = actionResultGen(lastStateProto, fr).map { ar =>
ars :+ actionResultProtoApplier
.applyActionResult(lastStateProto, ar)
}
)
}
override def withRandomActionResultT(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[ActionResultT]
): RandomStateProtoSequencerImpl =
results match {
case RandomState(ars, fr) =>
copy(
results = actionResultGen(lastStateProto, fr).map { ar =>
ars :+ actionResultProtoApplier
.applyActionResult(
lastStateProto,
ActionResultProtoConverter.toProto(ar)
)
}
)
}
override def withRandomActionResults(
actionResultsGen: (
GameStateProto,
FunctionalRandom
) => RandomState[Vector[ActionResultProto]]
): RandomStateProtoSequencer =
results match {
case RandomState(ars, fr) =>
copy(results = actionResultsGen(lastStateProto, fr).map { newArs =>
ars ++ actionResultProtoApplier.applyActionResults(
lastStateProto,
newArs
)
})
}
override def withRandomActionResultTs(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[Vector[ActionResultT]]
): RandomStateProtoSequencerImpl =
results match {
case RandomState(ars, fr) =>
copy(
results = actionResultGen(lastStateProto, fr).map { newArs =>
ars ++ actionResultProtoApplier
.applyActionResults(
lastStateProto,
newArs.map(ActionResultProtoConverter.toProto)
)
}
)
}
override def withRandomAction(
actionGen: (GameStateProto, FunctionalRandom) => RandomState[Action]
): RandomStateProtoSequencer =
results match {
case RandomState(ars, fr) =>
copy(results = actionGen(lastStateProto, fr).map { action =>
ars ++ action.execute(actionResultProtoApplier)
})
}
override def withRandomContinuance(
randomContinuance: RandomStateProtoSequencer => RandomStateProtoSequencer
): RandomStateProtoSequencer = randomContinuance(this)
}
@@ -0,0 +1,356 @@
package net.eagle0.eagle.library.actions.impl.common
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.{ActionResultTApplier, ActionResultTWithResultingState}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.state.game_state.GameState
trait RandomStateTSequencer {
def lastStateProto: GameStateProto
def actionResults: RandomState[Vector[ActionResultT]]
def actionResultTsWithResultingStates: RandomState[
Vector[ActionResultTWithResultingState]
]
def actionsWithResultingStates: RandomState[
Vector[ActionWithResultingState]
] = actionResultTsWithResultingStates.map {
_.map {
case ActionResultTWithResultingState(art, gs) =>
ActionWithResultingState(ActionResultProtoConverter.toProto(art), gs)
}
}
def withRandomActionResult(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[ActionResultT]
): RandomStateTSequencer
def withRandomActionResults(
actionResultsGen: FunctionalRandom => RandomState[Vector[ActionResultT]]
): RandomStateTSequencer =
withRandomActionResults((_, fr) => actionResultsGen(fr))
def withRandomActionResults(
actionResultsGen: (
GameStateProto,
FunctionalRandom
) => RandomState[Vector[ActionResultT]]
): RandomStateTSequencer
def withProtolessSimpleAction(
action: GameStateProto => ProtolessSimpleAction
): RandomStateTSequencer
def withProtolessRandomSimpleAction(
action: (GameStateProto, FunctionalRandom) => RandomState[ActionResultT]
): RandomStateTSequencer
def withProtolessSequentialResultsAction(
action: GameStateProto => ProtolessSequentialResultsAction
): RandomStateTSequencer
/**
* Execute a T-type command and add its results to the sequencer.
*
* This handles all three T-type command types:
* - Simple: single immediate result
* - RandomSimple: single result requiring randomness
* - Sequential: multiple results
*/
def withTCommand(
command: GameStateProto => TCommand
): RandomStateTSequencer
/**
* Execute a T-type command that may or may not exist.
*
* If the command generator returns None, no action is taken.
*/
def withOptionalTCommand(
command: GameStateProto => Option[TCommand]
): RandomStateTSequencer
/**
* Execute a T-type command with access to the FunctionalRandom.
*
* The command generator receives both the game state and the random state, useful when command selection itself
* requires randomness.
*/
def withRandomTCommand(
command: (GameStateProto, FunctionalRandom) => RandomState[TCommand]
): RandomStateTSequencer
/**
* Execute an optional T-type command with access to the FunctionalRandom.
*/
def withOptionalRandomTCommand(
command: (GameStateProto, FunctionalRandom) => RandomState[Option[TCommand]]
): RandomStateTSequencer
def withActionResult(
actionResultGen: GameStateProto => ActionResultT
): RandomStateTSequencer
def withActionResultT(
actionResultGen: GameStateProto => ActionResultT
): RandomStateTSequencer
def withActionResultTs(
actionResultGen: GameStateProto => Iterable[ActionResultT]
): RandomStateTSequencer
def withActionResults(
actionResultsGen: GameStateProto => Iterable[ActionResultT]
): RandomStateTSequencer
def withContinuance(
continuance: RandomStateTSequencer => RandomStateTSequencer
): RandomStateTSequencer
def withRandomContinuance(
randomContinuance: RandomStateTSequencer => RandomStateTSequencer
): RandomStateTSequencer
def foldIn[T](ts: Iterable[T])(
f: (T, GameStateProto, FunctionalRandom) => RandomState[ActionResultT]
): RandomStateTSequencer
}
object RandomStateTSequencer {
def apply(
initialState: GameState,
actionResultApplier: ActionResultTApplier,
functionalRandom: FunctionalRandom
): RandomStateTSequencer =
fromProto(
initialStateProto = GameStateConverter.toProto(initialState),
actionResultApplier = actionResultApplier,
functionalRandom = functionalRandom
)
def fromProto(
initialStateProto: GameStateProto,
actionResultApplier: ActionResultTApplier,
functionalRandom: FunctionalRandom
): RandomStateTSequencer =
RandomStateTSequencerImpl(
initialStateProto = initialStateProto,
actionResultApplier = actionResultApplier,
actionResultTsWithResultingStates = RandomState(
Vector(),
functionalRandom
)
)
}
private case class RandomStateTSequencerImpl(
initialStateProto: GameStateProto,
actionResultApplier: ActionResultTApplier,
actionResultTsWithResultingStates: RandomState[
Vector[ActionResultTWithResultingState]
]
) extends RandomStateTSequencer {
override def actionResults: RandomState[Vector[ActionResultT]] =
actionResultTsWithResultingStates.map(_.map(_.actionResult))
override def lastStateProto: GameStateProto =
actionResultTsWithResultingStates.newValue.lastOption
.map(_.resultingState)
.getOrElse(initialStateProto)
override def withProtolessSimpleAction(
action: GameStateProto => ProtolessSimpleAction
): RandomStateTSequencer =
withActionResultT(gs => action(gs).immediateExecute)
override def withProtolessRandomSimpleAction(
action: (GameStateProto, FunctionalRandom) => RandomState[ActionResultT]
): RandomStateTSequencer =
withRandomActionResult(action)
override def withProtolessSequentialResultsAction(
action: GameStateProto => ProtolessSequentialResultsAction
): RandomStateTSequencer =
withActionResults(gs => action(gs).results)
override def withTCommand(
command: GameStateProto => TCommand
): RandomStateTSequencer =
command(lastStateProto) match {
case TCommand.Simple(action) => withActionResultT(_ => action.immediateExecute)
case TCommand.RandomSimple(action) =>
withRandomActionResult((_, fr) => action.immediateExecute(fr))
case TCommand.Sequential(action) => withActionResults(_ => action.results)
}
override def withOptionalTCommand(
command: GameStateProto => Option[TCommand]
): RandomStateTSequencer =
command(lastStateProto) match {
case Some(cmd) => withTCommand(_ => cmd)
case None => this
}
override def withRandomTCommand(
command: (GameStateProto, FunctionalRandom) => RandomState[TCommand]
): RandomStateTSequencer =
actionResultTsWithResultingStates match {
case RandomState(_, fr) =>
command(lastStateProto, fr) match {
case RandomState(TCommand.Simple(action), newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(
actionResultTsWithResultingStates.newValue,
newFr
)
).withActionResultT(_ => action.immediateExecute)
case RandomState(TCommand.RandomSimple(action), newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(
actionResultTsWithResultingStates.newValue,
newFr
)
).withRandomActionResult((_, fr2) => action.immediateExecute(fr2))
case RandomState(TCommand.Sequential(action), newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(
actionResultTsWithResultingStates.newValue,
newFr
)
).withActionResults(_ => action.results)
}
}
override def withOptionalRandomTCommand(
command: (GameStateProto, FunctionalRandom) => RandomState[Option[TCommand]]
): RandomStateTSequencer =
actionResultTsWithResultingStates match {
case RandomState(_, fr) =>
command(lastStateProto, fr) match {
case RandomState(Some(cmd), newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(
actionResultTsWithResultingStates.newValue,
newFr
)
).withTCommand(_ => cmd)
case RandomState(None, newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(
actionResultTsWithResultingStates.newValue,
newFr
)
)
}
}
override def withActionResultT(
action: GameStateProto => ActionResultT
): RandomStateTSequencer =
withActionResult(gs => action(gs))
override def withActionResultTs(
action: GameStateProto => Iterable[ActionResultT]
): RandomStateTSequencer =
withActionResults(gs => action(gs))
override def withActionResult(
actionResultGen: GameStateProto => ActionResultT
): RandomStateTSequencer = actionResultGen(lastStateProto) match {
case art =>
copy(
actionResultTsWithResultingStates = actionResultTsWithResultingStates.map(
_ :+ ActionResultTWithResultingState(art, lastStateProto)
)
)
}
override def withActionResults(
actionResultsGen: GameStateProto => Iterable[ActionResultT]
): RandomStateTSequencer = actionResultsGen(lastStateProto).foldLeft(this) {
case (sequencer, ar) =>
sequencer.copy(actionResultTsWithResultingStates =
sequencer.actionResultTsWithResultingStates.map(
_ :+ actionResultApplier
.applyActionResult(
sequencer.lastStateProto,
ar
)
)
)
}
override def foldIn[T](ts: Iterable[T])(
f: (T, GameStateProto, FunctionalRandom) => RandomState[ActionResultT]
): RandomStateTSequencer =
ts.foldLeft(this) {
case (sequencer, t) =>
sequencer.withRandomActionResult { case (gs, fr) => f(t, gs, fr) }
}
override def withContinuance(
continuance: RandomStateTSequencer => RandomStateTSequencer
): RandomStateTSequencer = continuance(this)
override def withRandomActionResult(
actionResultGen: (
GameStateProto,
FunctionalRandom
) => RandomState[ActionResultT]
): RandomStateTSequencerImpl =
actionResultTsWithResultingStates match {
case RandomState(awrs, fr) =>
actionResultGen(lastStateProto, fr) match {
case RandomState(art, newFr) =>
copy(actionResultTsWithResultingStates =
RandomState(
awrs :+ actionResultApplier
.applyActionResult(
lastStateProto,
art
),
newFr
)
)
}
}
override def withRandomActionResults(
actionResultsGen: (
GameStateProto,
FunctionalRandom
) => RandomState[Vector[ActionResultT]]
): RandomStateTSequencer =
actionResultTsWithResultingStates match {
case RandomState(awrs, fr) =>
actionResultsGen(lastStateProto, fr) match {
case RandomState(Vector(), newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(awrs, newFr)
)
case RandomState(arts, newFr) =>
copy(
actionResultTsWithResultingStates = RandomState(
awrs ++ actionResultApplier
.applyActionResults(
lastStateProto,
arts
),
newFr
)
)
}
}
override def withRandomContinuance(
randomContinuance: RandomStateTSequencer => RandomStateTSequencer
): RandomStateTSequencer = randomContinuance(this)
}
@@ -54,9 +54,7 @@ case class HeroBackstoryUpdatePromptGenerator(
gameState: GameState,
clientTextStore: ClientTextStore
) extends LLMPromptGenerator {
private val normalGrowthRate = 30
private val slowGrowthRate = 8
private val softCap = 225 // approximately 1350 characters
private val maxWordCountIncrease = 30
private val hero = gameState.heroes.getOrElse(
backstoryUpdateRequest.heroId,
@@ -97,10 +95,8 @@ case class HeroBackstoryUpdatePromptGenerator(
"\n----\n"
)
} yield {
val previousWordCount = previousBackstoryText.split("\\W+").length
val newMaximumWordCount =
if previousWordCount >= softCap then previousWordCount + slowGrowthRate
else Math.min(previousWordCount + normalGrowthRate, softCap)
previousBackstoryText.split("\\W+").length + maxWordCountIncrease
s"""$setup
|
|$heroDescription

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