mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 10:35:42 +00:00
Compare commits
393
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c874f3e53 | ||
|
|
d210cbb820 | ||
|
|
50948bccae | ||
|
|
98a1c2b361 | ||
|
|
38608bd3e4 | ||
|
|
e4e43292df | ||
|
|
351cc6f658 | ||
|
|
603e7fb071 | ||
|
|
0ccf0539ba | ||
|
|
033e1f4675 | ||
|
|
c9aa6df721 | ||
|
|
734ea7c62e | ||
|
|
23f29249ef | ||
|
|
eeab47e480 | ||
|
|
22b52f498a | ||
|
|
f403980853 | ||
|
|
eb6fcb5ce6 | ||
|
|
299f4625cd | ||
|
|
84788452b3 | ||
|
|
5a81a203e8 | ||
|
|
9c60652eaa | ||
|
|
9b384234d8 | ||
|
|
ef591539d3 | ||
|
|
1db0c6b282 | ||
|
|
61da5c1e92 | ||
|
|
2d713f4e57 | ||
|
|
fab32ae871 | ||
|
|
bc762f3b45 | ||
|
|
7e5c5af876 | ||
|
|
c71c846a81 | ||
|
|
cfc218ba30 | ||
|
|
29a481f7b4 | ||
|
|
fce2a104e8 | ||
|
|
0fb419cbd8 | ||
|
|
bf83e7bde3 | ||
|
|
54bfea07d4 | ||
|
|
ab74172744 | ||
|
|
80cbe1b60f | ||
|
|
488ffbf559 | ||
|
|
50f9ccb28c | ||
|
|
34cce82cb0 | ||
|
|
f0baa8bdba | ||
|
|
52fe13325b | ||
|
|
de15e2a66a | ||
|
|
a506abae98 | ||
|
|
b43406b7c7 | ||
|
|
919990a95c | ||
|
|
48162b20c0 | ||
|
|
88433e481e | ||
|
|
cc5eb01dba | ||
|
|
d5dda77a47 | ||
|
|
663b2b34f7 | ||
|
|
1e936374b0 | ||
|
|
8adc8953cf | ||
|
|
ea5a16ba6b | ||
|
|
b4fae126ef | ||
|
|
d468037358 | ||
|
|
49f9e53be8 | ||
|
|
2c2e405ebc | ||
|
|
b49132e3de | ||
|
|
340bd806fa | ||
|
|
31e6277d87 | ||
|
|
e17115ee07 | ||
|
|
09023e9f1f | ||
|
|
b82c6a5dd8 | ||
|
|
b702f1f8bd | ||
|
|
9beb308b80 | ||
|
|
7f3b3b65a3 | ||
|
|
902513d0b5 | ||
|
|
0ddfeff937 | ||
|
|
d540ed425f | ||
|
|
ed636ef358 | ||
|
|
e5590bc848 | ||
|
|
5eb4ca38c9 | ||
|
|
f65e03137f | ||
|
|
35144f95fb | ||
|
|
5fd4182245 | ||
|
|
ffbdb5d5c7 | ||
|
|
b9b4d5254f | ||
|
|
e2a2a64204 | ||
|
|
30d8f8575a | ||
|
|
4cdd9371c6 | ||
|
|
f23e81dc52 | ||
|
|
3de29fd23e | ||
|
|
52143a9481 | ||
|
|
7a5185f6bf | ||
|
|
8ac97cda58 | ||
|
|
06e82a84cd | ||
|
|
abfd27b495 | ||
|
|
abb4504355 | ||
|
|
5cb1b155cd | ||
|
|
d258e65a6f | ||
|
|
9005ddf00c | ||
|
|
fd66a5d4dd | ||
|
|
1d06fa4368 | ||
|
|
9fdeb07d9c | ||
|
|
9893a79296 | ||
|
|
05f17ca569 | ||
|
|
7652b798a8 | ||
|
|
e27cbf1143 | ||
|
|
4276a211a0 | ||
|
|
0ebb240f6f | ||
|
|
07bf5fc72f | ||
|
|
09fb31fad4 | ||
|
|
989915a62d | ||
|
|
1f6ba6ff1f | ||
|
|
f7942f67fd | ||
|
|
a01935d1f7 | ||
|
|
be673810f6 | ||
|
|
fcc21b1d5c | ||
|
|
234979c2f6 | ||
|
|
806cdf4d68 | ||
|
|
befd7df406 | ||
|
|
fc3c88e6e6 | ||
|
|
94a7f01b35 | ||
|
|
c1dbebb60b | ||
|
|
9fa873ed53 | ||
|
|
816417931e | ||
|
|
ba1b62c792 | ||
|
|
4066996d77 | ||
|
|
1651c362fa | ||
|
|
10552ac843 | ||
|
|
b93bfc745a | ||
|
|
a539740046 | ||
|
|
f0f22b7818 | ||
|
|
0f7e8d201d | ||
|
|
9dc6a691f0 | ||
|
|
cae2cf4e07 | ||
|
|
3e58063303 | ||
|
|
1f0d70ccb2 | ||
|
|
38cf715f86 | ||
|
|
8bd118759a | ||
|
|
04d2e2d0cc | ||
|
|
454d4a4e12 | ||
|
|
6ee0485a05 | ||
|
|
cffc11ef5c | ||
|
|
81f0eca49a | ||
|
|
7a99609383 | ||
|
|
cf72e50e49 | ||
|
|
baf2dd184d | ||
|
|
f60bcc4fa1 | ||
|
|
b7e6b6a0e7 | ||
|
|
7101b83166 | ||
|
|
3052b65282 | ||
|
|
bc0b85e86a | ||
|
|
408979fd80 | ||
|
|
0bc84928c7 | ||
|
|
b475e806bd | ||
|
|
b987277f8b | ||
|
|
2536e43e56 | ||
|
|
ff7fcbcac8 | ||
|
|
a28d4d6631 | ||
|
|
6cb4591c4e | ||
|
|
9629bfd9de | ||
|
|
e11ddf3e21 | ||
|
|
bf48be685d | ||
|
|
ded1939106 | ||
|
|
3b378a6d02 | ||
|
|
786608a6ec | ||
|
|
7526817a3b | ||
|
|
46891abb5b | ||
|
|
ceed22d6de | ||
|
|
75f35e981e | ||
|
|
76d8f07827 | ||
|
|
705a36e901 | ||
|
|
758ae6d44f | ||
|
|
899162bbc5 | ||
|
|
d174c06ddb | ||
|
|
4935b81037 | ||
|
|
503801e7c4 | ||
|
|
646c47259c | ||
|
|
4e411695f2 | ||
|
|
237efb9c0c | ||
|
|
0368a9f24a | ||
|
|
39b8c3360e | ||
|
|
c2b04c2109 | ||
|
|
875f34a624 | ||
|
|
7f2cdbd111 | ||
|
|
a466c42cd8 | ||
|
|
40dd82e638 | ||
|
|
824748d88f | ||
|
|
0b70e6e9bf | ||
|
|
0ad09f4067 | ||
|
|
3c23afecfa | ||
|
|
596143f54d | ||
|
|
6cbc99ae73 | ||
|
|
2497e7114d | ||
|
|
fbe7f9606b | ||
|
|
1573e73419 | ||
|
|
5ea7908db0 | ||
|
|
090ce2b1f6 | ||
|
|
9d114b89a7 | ||
|
|
9112e60378 | ||
|
|
37fa64bdad | ||
|
|
9eca1f4a91 | ||
|
|
5d807ef268 | ||
|
|
c9b045cd78 | ||
|
|
f2c3772304 | ||
|
|
7ab1b44989 | ||
|
|
e584e85642 | ||
|
|
e543d7b53f | ||
|
|
c08ed8c170 | ||
|
|
86d396c5f5 | ||
|
|
e635e2d5cf | ||
|
|
266039df60 | ||
|
|
ec8223fb4f | ||
|
|
76d3d25d05 | ||
|
|
6672683289 | ||
|
|
91ee65f7d0 | ||
|
|
7d3dee125a | ||
|
|
8aedd945cf | ||
|
|
5d537db8c8 | ||
|
|
14100fcb50 | ||
|
|
dd0eba3f48 | ||
|
|
ff5e6bd461 | ||
|
|
c9268d61c8 | ||
|
|
8bb45a57af | ||
|
|
307ea245ee | ||
|
|
c0d00d4b81 | ||
|
|
b8cb342bc5 | ||
|
|
cf70169e3c | ||
|
|
e96a86f1a6 | ||
|
|
a07b679a5a | ||
|
|
c27715904c | ||
|
|
7830c42cd2 | ||
|
|
f4ef9d83c4 | ||
|
|
f76d4b785c | ||
|
|
7d6961fc86 | ||
|
|
9b6cbe6455 | ||
|
|
9eb90c2cb4 | ||
|
|
48a44e126a | ||
|
|
b8605a3662 | ||
|
|
2d3779448c | ||
|
|
7eb5894a57 | ||
|
|
e9f10ee540 | ||
|
|
58030a56a7 | ||
|
|
3a45347549 | ||
|
|
660d349e50 | ||
|
|
57c2862a57 | ||
|
|
3923ae8c11 | ||
|
|
d0e39bfc02 | ||
|
|
78ed12052b | ||
|
|
abdb91b8d9 | ||
|
|
2debfe546c | ||
|
|
5d81972093 | ||
|
|
c8aa26058b | ||
|
|
386d99d553 | ||
|
|
b3d99c6f85 | ||
|
|
afb143645a | ||
|
|
ba160b9669 | ||
|
|
5664f15dac | ||
|
|
8009ed62d2 | ||
|
|
8abd355883 | ||
|
|
fb8408fd64 | ||
|
|
9f155a359b | ||
|
|
19cf9c592a | ||
|
|
537f846fee | ||
|
|
2bd9fb4022 | ||
|
|
21eb10d2e5 | ||
|
|
1d99eefbd6 | ||
|
|
ebc29a75d4 | ||
|
|
f0d7067cc9 | ||
|
|
e05ea92c46 | ||
|
|
b0639dc8d0 | ||
|
|
9eb8f7f086 | ||
|
|
9f18322013 | ||
|
|
7306c9cb06 | ||
|
|
3ff012806f | ||
|
|
904dadf170 | ||
|
|
579d04eafd | ||
|
|
acdea65632 | ||
|
|
b4bb2f6d7f | ||
|
|
44546ac3e0 | ||
|
|
ab44d53b60 | ||
|
|
02cffbdccb | ||
|
|
152b72d691 | ||
|
|
ea7677e351 | ||
|
|
d57003e144 | ||
|
|
4c720983b7 | ||
|
|
5831dc6e7a | ||
|
|
79ee1e4e28 | ||
|
|
26100acbe5 | ||
|
|
0af9a11bc0 | ||
|
|
a9ebe48a76 | ||
|
|
a582af560d | ||
|
|
28f0081a86 | ||
|
|
fe6b50b055 | ||
|
|
4ee0230b18 | ||
|
|
af75f7af74 | ||
|
|
614534ac54 | ||
|
|
4696d56ec6 | ||
|
|
4af198fe1a | ||
|
|
e74e26f191 | ||
|
|
dadb374143 | ||
|
|
5bccde82ac | ||
|
|
b0c7c57402 | ||
|
|
f6850fc838 | ||
|
|
2f783f7ab0 | ||
|
|
7bdbd7ec8f | ||
|
|
334a431f14 | ||
|
|
4ba9a475da | ||
|
|
acb409bf3f | ||
|
|
0389357366 | ||
|
|
a470bb16da | ||
|
|
37ac215b85 | ||
|
|
830985e01f | ||
|
|
a50d64ee94 | ||
|
|
b8f6b99db2 | ||
|
|
c340f2b79d | ||
|
|
c94422f1cf | ||
|
|
4b4a4e7dc6 | ||
|
|
aba582bd10 | ||
|
|
a7d3b2b421 | ||
|
|
f6ff303ba9 | ||
|
|
5813bc9500 | ||
|
|
a912347dbd | ||
|
|
e2221c4e45 | ||
|
|
5af91f6e8b | ||
|
|
bc442ba230 | ||
|
|
3d169136a4 | ||
|
|
fae53369bf | ||
|
|
ae65205d34 | ||
|
|
d0c87c838e | ||
|
|
6c01a81bbf | ||
|
|
c25ecadd60 | ||
|
|
251aeb5794 | ||
|
|
9223b0452e | ||
|
|
05299dbe77 | ||
|
|
f06ba27a6f | ||
|
|
bf90c98add | ||
|
|
95afb025e1 | ||
|
|
93e3804eef | ||
|
|
6c13ade07e | ||
|
|
dee2574a59 | ||
|
|
8bb8a8f791 | ||
|
|
51b95d0b62 | ||
|
|
d06f26bffe | ||
|
|
7375d9f8ee | ||
|
|
0a650dec2c | ||
|
|
22cdf99d4d | ||
|
|
86e9489f21 | ||
|
|
2339050154 | ||
|
|
f7fefe078e | ||
|
|
ad3e7f19b9 | ||
|
|
022bdab53f | ||
|
|
c01e64265d | ||
|
|
a7572a5a9a | ||
|
|
44edd8cc5e | ||
|
|
3a1f1c2bbe | ||
|
|
0f3ffc24c2 | ||
|
|
741efecc66 | ||
|
|
4c7ac587e7 | ||
|
|
3c18918634 | ||
|
|
b930ca626f | ||
|
|
d1219787a8 | ||
|
|
8019986ea7 | ||
|
|
62b7e3abe1 | ||
|
|
88b049334b | ||
|
|
0aaa726abb | ||
|
|
37f75d4f6b | ||
|
|
b35a4b834e | ||
|
|
01a19f740d | ||
|
|
f86f53326f | ||
|
|
4fdc97590b | ||
|
|
1d46bdd452 | ||
|
|
292097f03e | ||
|
|
9a8ef4f243 | ||
|
|
c99d45bda7 | ||
|
|
18222f9cc4 | ||
|
|
344f1134f6 | ||
|
|
9aaca0d993 | ||
|
|
94db3634db | ||
|
|
19c8e63152 | ||
|
|
5d7f298d1c | ||
|
|
1cfd0ded6c | ||
|
|
ab69d40e64 | ||
|
|
be94acebbf | ||
|
|
4f5df7d697 | ||
|
|
c5af75db7d | ||
|
|
24421db613 | ||
|
|
35a804cf18 | ||
|
|
ba9091409a | ||
|
|
b4452d4e9c | ||
|
|
32f5c6838c | ||
|
|
8fa13da9c1 | ||
|
|
a2958a7672 | ||
|
|
1eb9f07c9c | ||
|
|
393fe7bdca | ||
|
|
51fc0ac99c | ||
|
|
f3211cf239 | ||
|
|
a73e758551 | ||
|
|
b47e37a083 | ||
|
|
bccd861230 |
@@ -1,4 +1,4 @@
|
||||
bazel-1.0.0.bazelrc
|
||||
# bazel-1.0.0.bazelrc
|
||||
|
||||
# for now: filter out annoying TASTY warnings
|
||||
common --ui_event_filters=-INFO
|
||||
@@ -29,6 +29,10 @@ common --host_cxxopt="--std=c++23"
|
||||
|
||||
common --javacopt="-Xlint:-options"
|
||||
|
||||
# std::filesystem and other modern C++ deps require macOS 10.15+.
|
||||
common:macos --macos_minimum_os=10.15
|
||||
common:macos --host_macos_minimum_os=10.15
|
||||
|
||||
# suppress warnings due to https://developer.apple.com/forums/thread/733317
|
||||
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
|
||||
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
@@ -37,11 +41,17 @@ common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
# See: https://github.com/grpc/grpc/issues/37619
|
||||
common:macos --features=-module_maps
|
||||
|
||||
# Pin DEVELOPER_DIR so the apple_cc_autoconf repo rule doesn't re-evaluate
|
||||
# when Xcode updates in-place. The sync script overrides this for mactools.
|
||||
# Pin DEVELOPER_DIR so Apple repo rules use full Xcode instead of Command Line
|
||||
# Tools. The sync script writes .bazelrc.xcode with an Xcode build-version
|
||||
# marker so in-place Xcode updates refresh Bazel's generated Apple toolchains.
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# Xcode config for mactools builds only. Generated by scripts/sync_bazel_xcode.sh.
|
||||
# Local machine or CI overrides. This lets Bazel-only runners use Command Line
|
||||
# Tools without installing full Xcode. This must stay before .bazelrc.xcode so
|
||||
# generated Xcode settings win when both files exist.
|
||||
try-import %workspace%/.bazelrc.local
|
||||
|
||||
# Xcode config for macOS builds. Generated by scripts/sync_bazel_xcode.sh.
|
||||
# Bakes the Xcode build version into action cache keys and sets DEVELOPER_DIR.
|
||||
try-import %workspace%/.bazelrc.xcode
|
||||
|
||||
|
||||
@@ -6,8 +6,44 @@ on:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
cleanup-expired:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete expired artifacts and artifacts older than 3 days
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Fetching all artifacts..."
|
||||
gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[] | "\(.id)\t\(.created_at)\t\(.expired)\t\(.name)"' > /tmp/all_artifacts.txt
|
||||
|
||||
total=$(wc -l < /tmp/all_artifacts.txt)
|
||||
echo "Found $total total artifacts"
|
||||
|
||||
cutoff=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
|
||||
echo "Deleting expired artifacts and artifacts created before $cutoff"
|
||||
|
||||
deleted=0
|
||||
while IFS=$'\t' read -r id created_at expired name; do
|
||||
if [[ "$expired" == "true" || "$created_at" < "$cutoff" ]]; then
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" 2>/dev/null && deleted=$((deleted + 1))
|
||||
if [ $((deleted % 50)) -eq 0 ]; then
|
||||
echo "Deleted $deleted artifacts so far..."
|
||||
fi
|
||||
fi
|
||||
done < /tmp/all_artifacts.txt
|
||||
|
||||
echo "Cleanup complete. Deleted $deleted artifacts out of $total total."
|
||||
rm -f /tmp/all_artifacts.txt
|
||||
|
||||
check-storage:
|
||||
needs: cleanup-expired
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -15,11 +51,12 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Calculate total artifact storage
|
||||
# Calculate active artifact storage. The artifacts API can list expired
|
||||
# artifacts until they are explicitly deleted, so do not count them.
|
||||
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
|
||||
--paginate -q '.artifacts[] | select(.expired == false) | .size_in_bytes' | awk '{sum+=$1} END {print sum}')
|
||||
|
||||
total_mb=$((total_bytes / 1024 / 1024))
|
||||
total_mb=$(( ${total_bytes:-0} / 1024 / 1024 ))
|
||||
echo "Total artifact storage: ${total_mb} MB"
|
||||
|
||||
# Fail if over 500MB
|
||||
@@ -29,7 +66,7 @@ jobs:
|
||||
echo "Largest artifacts:"
|
||||
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
|
||||
gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
|
||||
--paginate -q '.artifacts[] | select(.expired == false) | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
|
||||
sort -rn /tmp/artifacts.txt | head -20 | \
|
||||
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
|
||||
rm -f /tmp/artifacts.txt
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'src/main/resources/net/eagle0/attributions.json'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/auth_build.yml'
|
||||
workflow_dispatch:
|
||||
@@ -24,17 +25,25 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: auth-build-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Build Auth Server Docker image
|
||||
id: build-auth
|
||||
run: |
|
||||
@@ -127,10 +136,12 @@ jobs:
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
@@ -138,7 +149,7 @@ jobs:
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
script: |
|
||||
set -x
|
||||
set -e
|
||||
cd /opt/eagle0
|
||||
|
||||
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
name: Bazel Cache Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
# Disabled: bazel clean fails when another runner shares the output_base
|
||||
# on /Volumes/remote_cache (unlinkat "Directory not empty" race).
|
||||
# See https://github.com/nolen777/eagle0/issues/TBD for details.
|
||||
# schedule:
|
||||
# - cron: '0 0 * * 0'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Show disk usage before cleanup
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
@@ -19,6 +20,7 @@ on:
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
@@ -40,14 +42,32 @@ jobs:
|
||||
git config --local core.sparseCheckout false 2>/dev/null || true
|
||||
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
|
||||
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Check BUILD.bazel dependencies
|
||||
run: ./scripts/check_build_deps.sh --strict
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- name: Check JavaScript syntax
|
||||
@@ -62,10 +82,30 @@ jobs:
|
||||
git config --local core.sparseCheckout false 2>/dev/null || true
|
||||
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
|
||||
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Run tests
|
||||
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
|
||||
- name: Collect failed test logs
|
||||
@@ -102,14 +142,14 @@ jobs:
|
||||
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
|
||||
- name: Archive test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test.json
|
||||
path: test.json
|
||||
retention-days: 3
|
||||
- name: Archive failed test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: failed-test-logs
|
||||
path: failed_test_logs/
|
||||
|
||||
@@ -11,14 +11,18 @@ permissions:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Clean up unreferenced blobs
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
|
||||
@@ -27,13 +27,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build sysroot
|
||||
run: ./tools/sysroot/build_sysroot.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-amd64
|
||||
path: tools/sysroot/output/
|
||||
@@ -81,21 +83,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU for ARM64 emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build ARM64 sysroot
|
||||
run: ./tools/sysroot/build_sysroot_arm64.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-arm64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
@@ -18,6 +18,7 @@ on:
|
||||
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- 'MODULE.bazel'
|
||||
- '.bazelrc'
|
||||
- 'docker-compose.prod.yml'
|
||||
@@ -47,7 +48,7 @@ jobs:
|
||||
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
|
||||
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
|
||||
build-all:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
outputs:
|
||||
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
|
||||
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
|
||||
@@ -68,14 +69,38 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Fetch LFS files needed for admin server
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
|
||||
|
||||
- name: Build all Docker images
|
||||
@@ -117,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Upload warmup binary
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: warmup-binary
|
||||
path: scripts/bin/warmup
|
||||
@@ -202,7 +227,7 @@ jobs:
|
||||
echo "=== All images pushed successfully ==="
|
||||
|
||||
deploy:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
needs: [build-all]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
@@ -244,9 +269,26 @@ jobs:
|
||||
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
|
||||
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Setup SSH key
|
||||
@@ -257,11 +299,25 @@ jobs:
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Download warmup binary
|
||||
uses: actions/download-artifact@v4
|
||||
id: download-warmup
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: warmup-binary
|
||||
path: scripts/bin/
|
||||
|
||||
- name: Build warmup binary fallback
|
||||
if: steps.download-warmup.outcome != 'success'
|
||||
run: |
|
||||
echo "::warning::warmup-binary artifact was unavailable; rebuilding warmup binary before deploy"
|
||||
./ci/github_actions/ensure_bazel_installed.sh
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:warmup_tar
|
||||
mkdir -p scripts/bin
|
||||
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
|
||||
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
@@ -280,7 +336,7 @@ jobs:
|
||||
- name: Deploy to production droplet
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -ex
|
||||
set -e
|
||||
cd /opt/eagle0
|
||||
|
||||
# =================================================================
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/eagle_build.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -25,9 +26,29 @@ jobs:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Build Eagle server
|
||||
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
|
||||
|
||||
@@ -5,10 +5,12 @@ on:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "src/main/go/net/eagle0/clients/win/installer/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "src/main/go/net/eagle0/clients/win/installer/**"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -21,11 +23,32 @@ jobs:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Build Go installer for Windows
|
||||
env:
|
||||
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
|
||||
@@ -50,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Archive installer binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eagle-installer
|
||||
path: ./installer-output/
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
actions: write
|
||||
|
||||
env:
|
||||
# Runner-specific build directory to allow parallel builds on multiple runners
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
build-unity:
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
runs-on: [self-hosted, macOS, testflight, halfdan]
|
||||
|
||||
steps:
|
||||
- name: Prune stale PR refs
|
||||
@@ -122,8 +122,26 @@ jobs:
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
fetch-depth: 0
|
||||
@@ -134,6 +152,8 @@ jobs:
|
||||
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
|
||||
|
||||
- name: Fetch LFS files
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
@@ -164,17 +184,81 @@ jobs:
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: editor_ios.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
|
||||
retention-days: 3
|
||||
|
||||
- name: Package generated iOS project
|
||||
if: success()
|
||||
run: |
|
||||
tar -czf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR" eagle0iOS
|
||||
|
||||
- name: Upload generated iOS project
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eagle0-ios-project-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/eagle0-ios-project.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
archive-and-upload:
|
||||
needs: build-unity
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
runs-on: [self-hosted, macOS, testflight, halfdan]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Clean build directory
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
- name: Download generated iOS project
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: eagle0-ios-project-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Extract generated iOS project
|
||||
run: |
|
||||
mkdir -p "$EAGLE0_BUILD_DIR"
|
||||
tar -xzf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR"
|
||||
|
||||
- name: Delete generated iOS project artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
artifact_id=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate \
|
||||
--jq '.artifacts[] | select(.name == "eagle0-ios-project-${{ github.run_id }}") | .id')
|
||||
|
||||
if [ -n "$artifact_id" ]; then
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id"
|
||||
fi
|
||||
|
||||
- name: Install Signing Certificate
|
||||
env:
|
||||
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
|
||||
@@ -264,7 +348,7 @@ jobs:
|
||||
- name: Upload IPA artifact
|
||||
# Only keep artifact if we skipped TestFlight upload (for debugging)
|
||||
if: success() && github.event.inputs.skip_upload == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eagle0-ios-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
|
||||
|
||||
@@ -20,6 +20,7 @@ on:
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
@@ -39,6 +40,7 @@ on:
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/mac/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -64,7 +66,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-sign:
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
runs-on: [self-hosted, macOS, unity-mac, halfdan]
|
||||
outputs:
|
||||
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
|
||||
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
|
||||
@@ -81,8 +83,26 @@ jobs:
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
@@ -109,11 +129,16 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Fetch LFS files
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh mac
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Sync Bazel Xcode config
|
||||
run: ./scripts/sync_bazel_xcode.sh
|
||||
|
||||
@@ -122,7 +147,9 @@ jobs:
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
run: |
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
|
||||
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
@@ -227,7 +254,7 @@ jobs:
|
||||
|
||||
- name: Upload signed app
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: signed-mac-app-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
|
||||
@@ -235,7 +262,7 @@ jobs:
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
|
||||
@@ -247,18 +274,35 @@ jobs:
|
||||
wait-notarization:
|
||||
needs: build-and-sign
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, notarize]
|
||||
runs-on: [self-hosted, macOS, notarize, halfdan]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Download signed app
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: signed-mac-app-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
@@ -286,7 +330,7 @@ jobs:
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload notarized app
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: notarized-mac-app-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
|
||||
@@ -298,20 +342,37 @@ jobs:
|
||||
deploy:
|
||||
needs: [build-and-sign, wait-notarization]
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
runs-on: [self-hosted, macOS, notarize, halfdan]
|
||||
outputs:
|
||||
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # For version numbering
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Download notarized app
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: notarized-mac-app-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
@@ -322,6 +383,9 @@ jobs:
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Sync Bazel Xcode config
|
||||
run: ./scripts/sync_bazel_xcode.sh
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ jobs:
|
||||
echo "Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
# List of repositories to clean
|
||||
# Use tail to skip header row in case --no-header doesn't work
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
|
||||
# List repositories via JSON. Text output can wrap table rows and make
|
||||
# headers/tags/digests look like repository names.
|
||||
REPOS=$(doctl registry repository list-v2 "${REGISTRY}" --output json | jq -r '.[] | .name // .Name // empty')
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
@@ -88,5 +88,20 @@ jobs:
|
||||
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
|
||||
run: |
|
||||
echo "Starting garbage collection..."
|
||||
doctl registry garbage-collection start --force
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
set +e
|
||||
GC_OUTPUT=$(doctl registry garbage-collection start --force 2>&1)
|
||||
GC_STATUS=$?
|
||||
set -e
|
||||
|
||||
echo "$GC_OUTPUT"
|
||||
if [ "$GC_STATUS" -eq 0 ]; then
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$GC_OUTPUT" | grep -q "automated garbage collection is enabled"; then
|
||||
echo "Automated garbage collection is enabled for this registry; skipping manual garbage collection."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit "$GC_STATUS"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: S3 Archive Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Sunday at 05:00 UTC
|
||||
- cron: '0 5 * * 0'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
|
||||
steps:
|
||||
- name: Ensure AWS CLI is available
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
brew install awscli
|
||||
fi
|
||||
|
||||
- name: Delete old archived game folders
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
S3_ENDPOINT="https://sfo3.digitaloceanspaces.com"
|
||||
BUCKET="s3://eagle0/eagle/archived/"
|
||||
|
||||
# macOS date syntax
|
||||
CUTOFF=$(date -v-1m +%s)
|
||||
DELETED=0
|
||||
SKIPPED=0
|
||||
NO_DIR_FILE=0
|
||||
|
||||
echo "Cutoff date: $(date -r ${CUTOFF} '+%Y-%m-%dT%H:%M:%S')"
|
||||
echo ""
|
||||
|
||||
FOLDERS=$(aws s3 ls "$BUCKET" --endpoint-url "$S3_ENDPOINT" \
|
||||
| awk '/PRE/{gsub(/\/$/,"",$2); print $2}')
|
||||
|
||||
if [ -z "$FOLDERS" ]; then
|
||||
echo "No archived folders found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TOTAL=$(echo "$FOLDERS" | wc -l | tr -d ' ')
|
||||
CURRENT=0
|
||||
|
||||
for game_id in $FOLDERS; do
|
||||
CURRENT=$((CURRENT + 1))
|
||||
|
||||
DIR_INFO=$(aws s3 ls "${BUCKET}${game_id}/directory.e0i" \
|
||||
--endpoint-url "$S3_ENDPOINT" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$DIR_INFO" ]; then
|
||||
echo "[$CURRENT/$TOTAL] DELETE $game_id (no directory.e0i)"
|
||||
aws s3 rm --recursive "${BUCKET}${game_id}/" \
|
||||
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
|
||||
DELETED=$((DELETED + 1))
|
||||
NO_DIR_FILE=$((NO_DIR_FILE + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
FILE_DATE=$(echo "$DIR_INFO" | awk '{print $1 " " $2}')
|
||||
FILE_EPOCH=$(date -j -f '%Y-%m-%d %H:%M:%S' "$FILE_DATE" +%s 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$FILE_EPOCH" -lt "$CUTOFF" ]; then
|
||||
echo "[$CURRENT/$TOTAL] DELETE $game_id (directory.e0i from $FILE_DATE)"
|
||||
aws s3 rm --recursive "${BUCKET}${game_id}/" \
|
||||
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
|
||||
DELETED=$((DELETED + 1))
|
||||
else
|
||||
echo "[$CURRENT/$TOTAL] KEEP $game_id (directory.e0i from $FILE_DATE)"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "Total folders: $TOTAL"
|
||||
echo "Deleted: $DELETED"
|
||||
echo "Kept (recent): $SKIPPED"
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/resources/net/eagle0/shardok/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- 'MODULE.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/shardok_arm64_build.yml'
|
||||
@@ -29,15 +30,19 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
|
||||
run: |
|
||||
set -ex
|
||||
@@ -165,7 +170,7 @@ jobs:
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
deploy-hetzner:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: [self-hosted, bazel, halfdan]
|
||||
needs: [build-shardok-arm64]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
@@ -180,33 +185,55 @@ jobs:
|
||||
# Add host key to known_hosts to avoid prompt
|
||||
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Pull Shardok image tarball for Hetzner
|
||||
env:
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Hetzner is IPv6-only, while Docker can be redirected to IPv4-only
|
||||
# DigitalOcean registry/blob endpoints. Pull on the runner and copy
|
||||
# the Docker-loadable tarball over SSH instead.
|
||||
CRANE_VERSION="v0.20.2"
|
||||
CRANE_DIR="${HOME}/.local/bin"
|
||||
CRANE="${CRANE_DIR}/crane"
|
||||
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
|
||||
echo "Installing crane ${CRANE_VERSION}..."
|
||||
mkdir -p "$CRANE_DIR"
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
|
||||
chmod +x "$CRANE"
|
||||
else
|
||||
echo "Using cached crane at $CRANE"
|
||||
fi
|
||||
|
||||
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
IMAGE_TAR="${RUNNER_TEMP}/shardok-arm64-image.tar"
|
||||
"$CRANE" pull "${SHARDOK_IMAGE}" "$IMAGE_TAR"
|
||||
echo "SHARDOK_IMAGE_TAR=$IMAGE_TAR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Copy Shardok image tarball to Hetzner
|
||||
run: |
|
||||
set -ex
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} \
|
||||
"cat > /tmp/shardok-arm64-image.tar" < "$SHARDOK_IMAGE_TAR"
|
||||
|
||||
- name: Deploy to Hetzner
|
||||
run: |
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
|
||||
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
|
||||
|
||||
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
|
||||
for attempt in 1 2 3; do
|
||||
echo "Pull attempt $attempt..."
|
||||
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Pull failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
SHARDOK_IMAGE="${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
echo "Loading Shardok ARM64 image: $SHARDOK_IMAGE"
|
||||
docker load -i /tmp/shardok-arm64-image.tar
|
||||
rm -f /tmp/shardok-arm64-image.tar
|
||||
docker image inspect "$SHARDOK_IMAGE" > /dev/null
|
||||
echo "Loaded image ID: $(docker image inspect --format '{{.Id}}' "$SHARDOK_IMAGE")"
|
||||
echo "Shardok binary SHA256:"
|
||||
docker run --rm --entrypoint sha256sum "$SHARDOK_IMAGE" /app/shardok-server
|
||||
|
||||
# Stop and remove any container using port 40042 or named shardok*
|
||||
docker ps -q --filter "publish=40042" | xargs -r docker stop
|
||||
@@ -224,11 +251,12 @@ jobs:
|
||||
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
|
||||
-e SHARDOK_RESOURCES_PATH=/app/resources \
|
||||
-e SHARDOK_MAPS_PATH=/app/resources/maps \
|
||||
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
"$SHARDOK_IMAGE"
|
||||
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
echo "Running container image ID: $(docker inspect --format '{{.Image}}' shardok-ai)"
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
@@ -5,13 +5,14 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/proto/net/eagle0/shardok/**'
|
||||
- 'src/main/proto/net/eagle0/common/**'
|
||||
- 'src/main/protobuf/net/eagle0/shardok/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/go/net/eagle0/build/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -26,9 +27,29 @@ jobs:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Build Shardok server
|
||||
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
@@ -15,6 +15,8 @@ on:
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.sh"
|
||||
- "ci/github_actions/test_unity_editmode.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
@@ -34,6 +36,8 @@ on:
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.sh"
|
||||
- "ci/github_actions/test_unity_editmode.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
@@ -53,7 +57,7 @@ env:
|
||||
|
||||
jobs:
|
||||
windows-unity:
|
||||
runs-on: [self-hosted, macOS, unity-windows]
|
||||
runs-on: [self-hosted, macOS, unity-windows, halfdan]
|
||||
outputs:
|
||||
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
|
||||
|
||||
@@ -69,42 +73,88 @@ jobs:
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
|
||||
- name: Clean stale files
|
||||
run: |
|
||||
git clean -ffd
|
||||
LIBRARY_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library"
|
||||
VERSION_CACHE="$LIBRARY_DIR/.last_unity_version"
|
||||
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
CURRENT_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
|
||||
|
||||
# Nuke Library/ when Unity version changes to avoid import loops
|
||||
if [ -f "$VERSION_CACHE" ]; then
|
||||
CACHED_VERSION=$(cat "$VERSION_CACHE")
|
||||
if [ "$CACHED_VERSION" != "$CURRENT_VERSION" ]; then
|
||||
echo "Unity version changed ($CACHED_VERSION -> $CURRENT_VERSION) -- clearing Library/"
|
||||
rm -rf "$LIBRARY_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Only clear Bee/ when C# files were added/deleted/renamed (structural
|
||||
# changes that stale the DAG). Content-only modifications are handled by
|
||||
# Bee's incremental compilation. See persist_library.sh for background.
|
||||
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
|
||||
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
|
||||
BEE_DIR="$LIBRARY_DIR/Bee"
|
||||
SHA_FILE="$LIBRARY_DIR/.last_built_sha"
|
||||
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
|
||||
LAST_SHA=$(cat "$SHA_FILE")
|
||||
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
|
||||
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
|
||||
echo "C# files added/deleted/renamed since $LAST_SHA -- clearing Bee/"
|
||||
rm -rf "$BEE_DIR"
|
||||
else
|
||||
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
|
||||
echo "No structural C# changes since $LAST_SHA -- keeping Bee/"
|
||||
fi
|
||||
else
|
||||
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
|
||||
echo "No previous build SHA or no Bee/ -- clearing Bee/ as safe default"
|
||||
rm -rf "$BEE_DIR"
|
||||
fi
|
||||
|
||||
- name: Fetch LFS files
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh windows
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Run Unity EditMode tests
|
||||
run: ./ci/github_actions/test_unity_editmode.sh
|
||||
- name: Build Windows unity
|
||||
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
run: |
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
|
||||
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Save Unity version for Library/ cache invalidation
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
|
||||
grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //' > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_unity_version
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
@@ -152,11 +202,20 @@ jobs:
|
||||
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: editor_win.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
|
||||
retention-days: 3
|
||||
- name: Archive EditMode test artifacts
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unity_editmode_tests
|
||||
path: |
|
||||
${{ env.EAGLE0_BUILD_DIR }}/editor_editmode_tests.log
|
||||
${{ env.EAGLE0_BUILD_DIR }}/editmode-test-results.xml
|
||||
retention-days: 3
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
@@ -172,4 +231,4 @@ jobs:
|
||||
run: |
|
||||
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
|
||||
-H "X-Notify-Secret: $NOTIFY_SECRET" \
|
||||
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
|
||||
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
|
||||
|
||||
@@ -23,6 +23,7 @@ bazel-bin
|
||||
bazel-eagle0*
|
||||
bazel-out
|
||||
bazel-testlogs
|
||||
.bazelrc.local
|
||||
.bazelrc.xcode
|
||||
.ijwb
|
||||
.clwb
|
||||
@@ -43,3 +44,5 @@ src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
node_modules/
|
||||
tools/map_generator/output/
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_asset_usage_audit_files.csv
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_lfs_asset_audit.csv
|
||||
|
||||
@@ -34,8 +34,8 @@ repos:
|
||||
hooks:
|
||||
- id: scalafmt
|
||||
name: scalafmt
|
||||
language: system
|
||||
entry: scalafmt -i -f
|
||||
language: system
|
||||
entry: ./scripts/pre-commit-scalafmt.sh
|
||||
types_or: ["scala"]
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
# AGENTS.md
|
||||
|
||||
## CRITICAL BASH RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
|
||||
|
||||
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
|
||||
|
||||
**ALWAYS use this workflow:**
|
||||
1. Create a feature branch from origin/main
|
||||
2. Commit to that branch
|
||||
3. Create a PR with `gh pr create`
|
||||
4. Wait for user to merge (DO NOT run `gh pr merge`)
|
||||
|
||||
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
|
||||
|
||||
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
|
||||
|
||||
## Worktree Usage
|
||||
|
||||
Prefer using the current worktree for small requested fixes. Create a separate worktree only when the user asks for one,
|
||||
when the current branch/state would make the change unsafe, or when isolating a large/risky change is clearly beneficial.
|
||||
|
||||
**NEVER leave the current worktree without explicit user approval.** Do not create, switch to, edit, commit in, or run
|
||||
git commands from another worktree unless the user has specifically asked for that worktree or approved the move. If the
|
||||
current worktree is unsafe because of dirty state, report that and ask before using a different worktree.
|
||||
|
||||
Codex may edit code and run git commands in the current worktree, even when unrelated files are dirty, as long as it:
|
||||
- does not revert, reset, clean, or overwrite unrelated changes
|
||||
- stages only the files relevant to the requested change
|
||||
- verifies the staged diff before committing
|
||||
- creates feature branches and PRs as usual
|
||||
- does not push to main/master or merge PRs
|
||||
|
||||
When the user says a Codex-created PR has been merged, delete the corresponding local branch and temporary worktree
|
||||
automatically, unless there is uncommitted work that would make cleanup unsafe.
|
||||
|
||||
## PR Timing
|
||||
|
||||
When code changes appear correct locally, create the PR promptly even if long-running builds or tests are still running.
|
||||
Opening the PR starts CI on the build machines, uploads results to the Bazel remote cache, and can speed up subsequent
|
||||
local validation. Continue monitoring any already-started local and CI validation after opening the PR.
|
||||
|
||||
---
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based
|
||||
combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Three-Tier Game System:**
|
||||
|
||||
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
|
||||
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
|
||||
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle
|
||||
resolution
|
||||
|
||||
**Communication Flow:**
|
||||
|
||||
```
|
||||
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
|
||||
```
|
||||
|
||||
**Key Entry Points:**
|
||||
|
||||
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
|
||||
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
|
||||
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
|
||||
|
||||
**Protocol Buffer Architecture:**
|
||||
|
||||
- Extensive use of protobuf for type-safe communication
|
||||
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
|
||||
- Event sourcing pattern with immutable action history
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build Eagle server (Scala strategic layer)
|
||||
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
# Build Shardok server (C++ tactical layer)
|
||||
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Shardok server includes both AI algorithms
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Build Unity/C# client
|
||||
./scripts/build_protos.sh # Protocol buffer generation for Unity
|
||||
./scripts/build_plugins.sh # Native plugins for all platforms
|
||||
./scripts/build_windows_plugin.sh # Windows-specific plugin build
|
||||
# Unity builds via CI: ci/github_actions/build_unity.sh
|
||||
```
|
||||
|
||||
### Running Services
|
||||
|
||||
```bash
|
||||
# Eagle server (port 40032)
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
|
||||
# Or: ./scripts/eagle_run.sh
|
||||
|
||||
# Shardok server
|
||||
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=opt
|
||||
# Or: ./scripts/shardok_run.sh
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bazel test //src/test/... //src/main/go/...
|
||||
|
||||
# Component-specific tests
|
||||
bazel test //src/test/scala/... # Scala Eagle tests
|
||||
bazel test //src/test/cpp/... # C++ Shardok tests
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
```bash
|
||||
bazel run gazelle # Update Go build files
|
||||
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
|
||||
```
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
**MANDATORY: Before running `git commit`, verify:**
|
||||
|
||||
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
|
||||
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
|
||||
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
|
||||
4. **ALWAYS run the full test suite for your language changes before pushing:**
|
||||
- For Scala changes: `bazel test //src/test/scala/...`
|
||||
- For C++ changes: `bazel test //src/test/cpp/...`
|
||||
- This catches exhaustive pattern match errors and other compile-time failures that single-target builds miss
|
||||
|
||||
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
# ALWAYS run clang-format after making any C++ or C# code changes
|
||||
clang-format -i <modified_files>
|
||||
|
||||
# Format all C++ files in a directory:
|
||||
find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
|
||||
|
||||
# Format all C# files in a directory:
|
||||
find . -name "*.cs" | xargs clang-format -i
|
||||
```
|
||||
|
||||
### Static Analysis
|
||||
|
||||
```bash
|
||||
# Run clang-tidy static analysis on C++ files
|
||||
# Note: This may show some header include errors but will still analyze the main file
|
||||
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' <file_path> -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
|
||||
|
||||
# Example for AI files:
|
||||
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
|
||||
```
|
||||
|
||||
## AI Algorithm Selection
|
||||
|
||||
Eagle0 supports two AI algorithms for tactical combat decision-making:
|
||||
|
||||
### Iterative Deepening AI (Default)
|
||||
|
||||
The original minimax-based AI with sophisticated randomness handling:
|
||||
|
||||
- **Advantages**: Proven, sophisticated randomness evaluation, comprehensive lookahead
|
||||
- **Use cases**: Production builds, scenarios requiring precise evaluation
|
||||
- **Performance**: Single-threaded, thorough evaluation
|
||||
|
||||
### Monte Carlo Tree Search AI (MCTS)
|
||||
|
||||
Modern MCTS-based AI with multithreading support:
|
||||
|
||||
- **Advantages**: Multithreaded, better performance on modern CPUs, anytime algorithm
|
||||
- **Use cases**: Performance testing, scenarios requiring fast decisions
|
||||
- **Performance**: Multithreaded, adaptive depth based on time budget
|
||||
|
||||
### Switching Between Algorithms
|
||||
|
||||
The algorithm is selected at **runtime** via the ShardokAIClient constructor:
|
||||
|
||||
```cpp
|
||||
// Using Iterative Deepening AI (default)
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings);
|
||||
// OR explicitly:
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
|
||||
// Using MCTS AI
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build the server (includes both AI algorithms)
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Test both algorithms
|
||||
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_iterative_deepening_test
|
||||
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_mcts_test # If available
|
||||
|
||||
# Performance tests
|
||||
./scripts/ai_perf_test.sh # Uses whatever algorithm the server is configured to use
|
||||
```
|
||||
|
||||
Both implementations are compatible with all existing interfaces and produce the same `SearchResult` structure.
|
||||
|
||||
**Note**: Both implementations are documented in `src/main/cpp/net/eagle0/shardok/ai/AI_SCORING_SYSTEM.md`, including
|
||||
recommendations for improving MCTS randomness handling.
|
||||
|
||||
The AI algorithm selection is made at runtime when creating ShardokAIClient instances, allowing different AI strategies
|
||||
to be used for different players or game situations within the same server process.
|
||||
|
||||
## Language-Specific Patterns
|
||||
|
||||
**Scala (Strategic Layer):**
|
||||
|
||||
- Use `EngineImpl.scala` for core game logic modifications
|
||||
- Follow event sourcing pattern - all changes through immutable actions
|
||||
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
|
||||
- LLM integration in `/common/llm_integration/` for narrative generation
|
||||
|
||||
**C++ (Tactical Layer):**
|
||||
|
||||
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
|
||||
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
|
||||
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
|
||||
- Extensive unit testing with Google Test framework
|
||||
|
||||
**Protocol Buffers:**
|
||||
|
||||
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
|
||||
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
|
||||
- Maintain backward compatibility when modifying existing messages
|
||||
|
||||
**C# (Unity Client):**
|
||||
|
||||
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
|
||||
- Uses Unity 6 (6000.4.9f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
|
||||
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
|
||||
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
|
||||
- Seamless transition between strategic gameplay and hex-based tactical combat
|
||||
- **NEVER add defensive null checks on Unity Inspector fields** - these hide configuration bugs. If a field isn't
|
||||
linked in the editor, it should throw a NullReferenceException so the problem is immediately obvious. Silently
|
||||
skipping code when a required field is null makes bugs harder to find.
|
||||
|
||||
**Go (Build Tools):**
|
||||
|
||||
- Build automation and code generation utilities
|
||||
- AWS S3 integration for deployment artifacts
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Comprehensive unit tests for both Scala and C++ components
|
||||
- Integration tests for Eagle-Shardok communication
|
||||
- Map validation tests ensure game content integrity
|
||||
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
# 1. Commit your changes to a feature branch
|
||||
git checkout -b performance-improvement-feature
|
||||
git add . && git commit -m "Implement performance improvement"
|
||||
|
||||
# 2. Run performance tests multiple times on your branch to reduce noise
|
||||
for i in 1 2 3; do
|
||||
echo "=== Run $i ==="
|
||||
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
|
||||
done
|
||||
# Save or note the results
|
||||
|
||||
# 3. Switch to main branch and run the same tests
|
||||
git checkout main
|
||||
for i in 1 2 3; do
|
||||
echo "=== Run $i ==="
|
||||
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
|
||||
done
|
||||
|
||||
# 4. Compare the results between your branch and main
|
||||
# Key metrics to compare:
|
||||
# - Commands evaluated at each depth (e.g., "Depth 3: 169/523 commands")
|
||||
# - Average search depth achieved
|
||||
# - Completion rates at each depth
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
|
||||
- Run tests multiple times (3-5) to account for performance variance
|
||||
- Focus on commands evaluated at each depth rather than total commands
|
||||
- Commands at different depths aren't directly comparable (depth 3 is more valuable than depth 2)
|
||||
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
|
||||
behavior changes.
|
||||
|
||||
## Troubleshooting Scala Build Errors
|
||||
|
||||
### MissingType Errors
|
||||
|
||||
When you see errors like:
|
||||
```
|
||||
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
|
||||
```
|
||||
|
||||
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
|
||||
|
||||
**How to fix:**
|
||||
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
|
||||
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
|
||||
3. Add it to the `deps` of the failing target
|
||||
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
|
||||
|
||||
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
|
||||
|
||||
### Bazel Clean
|
||||
|
||||
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
|
||||
- Missing imports in Scala code
|
||||
- Missing dependencies in BUILD.bazel
|
||||
- Missing exports for types used in public signatures
|
||||
|
||||
## Game Content
|
||||
|
||||
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
|
||||
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
|
||||
**Data Files:** TSV format for battalions, heroes, and other game data
|
||||
|
||||
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Bazel handles multi-language builds and dependencies
|
||||
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
|
||||
- Docker containerization available via `ci/eagle_run.Dockerfile`
|
||||
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
|
||||
|
||||
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER use `git -C`.** Instead, cd to the repo root and run git commands from there.
|
||||
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
@@ -226,7 +228,7 @@ to be used for different players or game situations within the same server proce
|
||||
**C# (Unity Client):**
|
||||
|
||||
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
|
||||
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Uses Unity 6 (6000.4.9f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
|
||||
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
|
||||
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
|
||||
@@ -350,4 +352,4 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
|
||||
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
|
||||
- Docker containerization available via `ci/eagle_run.Dockerfile`
|
||||
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
|
||||
@@ -295,6 +295,9 @@ maven.install(
|
||||
# Testing
|
||||
"org.scalamock:scalamock_3:7.4.1",
|
||||
|
||||
# Developer tools
|
||||
"org.scalameta:scalafmt-cli_2.13:3.9.9",
|
||||
|
||||
# AWS SDK
|
||||
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:s3:%s" % AWS_SDK_VERSION,
|
||||
@@ -337,6 +340,15 @@ maven.install(
|
||||
],
|
||||
)
|
||||
|
||||
# json4s' Scala 3 reflection path can load scala.quoted.staging at runtime, but
|
||||
# its Maven metadata does not bring the staging jar transitively.
|
||||
maven.artifact(
|
||||
artifact = "scala3-staging_3",
|
||||
exclusions = ["org.scala-lang:scala3-compiler_3"],
|
||||
group = "org.scala-lang",
|
||||
version = "3.3.6",
|
||||
)
|
||||
|
||||
# Force specific versions for dependencies with conflicts between grpc-java and protobuf
|
||||
maven.artifact(
|
||||
artifact = "gson",
|
||||
|
||||
+38
-7
@@ -1,5 +1,6 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
load("//ci:jar_split.bzl", "jar_split")
|
||||
|
||||
#
|
||||
# Deployment artifacts (tools needed on the host, not in containers)
|
||||
@@ -50,11 +51,37 @@ pkg_tar(
|
||||
# Push: bazel run //ci:eagle_server_push
|
||||
#
|
||||
|
||||
# Package the deploy JAR
|
||||
# Split the Eagle server runtime classpath into a stable third-party layer
|
||||
# (Scala stdlib, gRPC, Netty, ScalaPB, AWS SDK, ...) and a small first-party
|
||||
# layer that changes every commit. Most pushes then only re-upload the small
|
||||
# app layer instead of the whole ~150-300MB fat JAR.
|
||||
jar_split(
|
||||
name = "eagle_server_jars",
|
||||
binary = "//src/main/scala/net/eagle0/eagle:eagle_server",
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "eagle_server_deps_jars",
|
||||
srcs = [":eagle_server_jars"],
|
||||
output_group = "deps",
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "eagle_server_app_jars",
|
||||
srcs = [":eagle_server_jars"],
|
||||
output_group = "app",
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "eagle_server_jar_layer",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
|
||||
package_dir = "/app",
|
||||
name = "eagle_server_deps_layer",
|
||||
srcs = [":eagle_server_deps_jars"],
|
||||
package_dir = "/app/lib/deps",
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "eagle_server_app_layer",
|
||||
srcs = [":eagle_server_app_jars"],
|
||||
package_dir = "/app/lib/app",
|
||||
)
|
||||
|
||||
# Package the game resources needed at runtime
|
||||
@@ -82,8 +109,11 @@ oci_image(
|
||||
"-XX:+UnlockDiagnosticVMOptions",
|
||||
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
|
||||
"-XX:FlightRecorderOptions=stackdepth=256",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
# Classpath glob is expanded by the JVM itself (exec-form, no shell).
|
||||
# Deps dir first keeps third-party precedence; app last shadows nothing.
|
||||
"-cp",
|
||||
"/app/lib/deps/*:/app/lib/app/*",
|
||||
"net.eagle0.eagle.Main",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
@@ -91,7 +121,8 @@ oci_image(
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_server_deps_layer",
|
||||
":eagle_server_app_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
|
||||
@@ -38,3 +38,25 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo "=== End of Unity Editor Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail the build if any prefab references are broken — this produces a player
|
||||
# that launches but has null Inspector fields, which is hard to debug.
|
||||
if grep -q "Missing Prefab" "$LOG_PATH"; then
|
||||
echo ""
|
||||
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
|
||||
grep "Missing Prefab" "$LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "Build asset version error" "$LOG_PATH"; then
|
||||
echo ""
|
||||
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
|
||||
grep "Build asset version error" "$LOG_PATH" | head -5
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -53,6 +53,22 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
# Fail the build if any prefab references are broken — this produces a player
|
||||
# that launches but has null Inspector fields, which is hard to debug.
|
||||
if grep -q "Missing Prefab" "$LOG_PATH"; then
|
||||
echo ""
|
||||
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
|
||||
grep "Missing Prefab" "$LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "Build asset version error" "$LOG_PATH"; then
|
||||
echo ""
|
||||
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
|
||||
grep "Build asset version error" "$LOG_PATH" | head -5
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "iOS Unity build complete"
|
||||
echo "Xcode project generated at: $BUILD_PATH"
|
||||
ls -la "$BUILD_PATH"
|
||||
|
||||
@@ -38,3 +38,25 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo "=== End of Unity Editor Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail the build if any prefab references are broken — this produces a player
|
||||
# that launches but has null Inspector fields, which is hard to debug.
|
||||
if grep -q "Missing Prefab" "$LOG_PATH"; then
|
||||
echo ""
|
||||
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
|
||||
grep "Missing Prefab" "$LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "Build asset version error" "$LOG_PATH"; then
|
||||
echo ""
|
||||
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
|
||||
grep "Build asset version error" "$LOG_PATH" | head -5
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
|
||||
write_developer_dir_override() {
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local developer_dir=""
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
developer_dir="/Applications/Xcode.app/Contents/Developer"
|
||||
elif [ -d "/Library/Developer/CommandLineTools" ]; then
|
||||
developer_dir="/Library/Developer/CommandLineTools"
|
||||
else
|
||||
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$developer_dir" ] || [ ! -d "$developer_dir" ]; then
|
||||
echo "ERROR: no usable Apple developer directory found"
|
||||
echo "Install Command Line Tools with: xcode-select --install"
|
||||
echo "Full Xcode is only required for workflows that build/sign Apple apps."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEVELOPER_DIR="$developer_dir"
|
||||
if [ -f .bazelrc.local ] && [ -z "${CI:-}" ]; then
|
||||
echo "Keeping existing .bazelrc.local outside CI"
|
||||
else
|
||||
{
|
||||
echo "# Generated by ci/github_actions/ensure_bazel_installed.sh; do not edit."
|
||||
printf 'common:macos --repo_env=DEVELOPER_DIR=%s\n' "$developer_dir"
|
||||
} > .bazelrc.local
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "DEVELOPER_DIR=$developer_dir" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
echo "Using Apple developer directory at $developer_dir"
|
||||
}
|
||||
|
||||
write_developer_dir_override
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if command -v bazel >/dev/null 2>&1; then
|
||||
echo "Using bazel at $(command -v bazel)"
|
||||
bazel --version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v bazelisk >/dev/null 2>&1; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "ERROR: neither bazel nor bazelisk is on PATH, and Homebrew is unavailable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing bazelisk with Homebrew"
|
||||
brew install bazelisk
|
||||
fi
|
||||
|
||||
BAZEL_BIN_DIR="${RUNNER_TEMP:-/tmp}/bazel-bin"
|
||||
mkdir -p "$BAZEL_BIN_DIR"
|
||||
ln -sf "$(command -v bazelisk)" "$BAZEL_BIN_DIR/bazel"
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$BAZEL_BIN_DIR" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
export PATH="$BAZEL_BIN_DIR:$PATH"
|
||||
echo "Using bazelisk as bazel at $BAZEL_BIN_DIR/bazel"
|
||||
bazel --version
|
||||
@@ -11,16 +11,46 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
|
||||
MAX_ATTEMPTS=5
|
||||
RETRY_DELAY=15
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if ! git lfs version >/dev/null 2>&1; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "ERROR: git-lfs is unavailable, and Homebrew is not on PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing git-lfs with Homebrew"
|
||||
brew install git-lfs
|
||||
fi
|
||||
|
||||
echo "Using git-lfs at $(command -v git-lfs)"
|
||||
git lfs version
|
||||
|
||||
git lfs install
|
||||
|
||||
echo "LFS objects before pull:"
|
||||
git lfs ls-files | wc -l
|
||||
|
||||
GIT_LFS_PULL=(git lfs pull)
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
BASIC_AUTH=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
|
||||
GIT_LFS_PULL=(git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $BASIC_AUTH" lfs pull)
|
||||
fi
|
||||
|
||||
for i in $(seq 1 $MAX_ATTEMPTS); do
|
||||
if git lfs pull "$@"; then
|
||||
if "${GIT_LFS_PULL[@]}" "$@"; then
|
||||
echo "LFS objects after pull:"
|
||||
git lfs ls-files | wc -l
|
||||
exit 0
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Read Unity version from project file
|
||||
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
|
||||
|
||||
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
|
||||
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
LOG_PATH="${BUILD_BASE}/editor_editmode_tests.log"
|
||||
RESULTS_PATH="${BUILD_BASE}/editmode-test-results.xml"
|
||||
|
||||
mkdir -p "$BUILD_BASE"
|
||||
|
||||
echo "Building protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
echo "Running Unity EditMode tests"
|
||||
|
||||
set +e
|
||||
"${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity" \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-executeMethod eagle0.Tests.DynamicTextBindingTestRunner.Run \
|
||||
-testResults "$RESULTS_PATH" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
UNITY_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Unity EditMode tests failed with exit code $UNITY_EXIT_CODE"
|
||||
echo "=== Unity EditMode Test Log (last 200 lines) ==="
|
||||
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
|
||||
echo "=== End of Unity EditMode Test Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$RESULTS_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write test results at $RESULTS_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Unity EditMode tests complete"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Split a JVM binary's runtime classpath into first-party and third-party jar sets.
|
||||
|
||||
This exists so the Docker image can place rarely-changing third-party jars in a
|
||||
lower OCI layer and frequently-changing first-party jars in a small top layer,
|
||||
so most pushes only re-upload the small layer.
|
||||
"""
|
||||
|
||||
def _unique_name(jar):
|
||||
# short_path is unique per jar and stable across commits (it depends only on
|
||||
# the jar's own package/coordinate, not on unrelated targets), so the deps
|
||||
# layer's tar entries stay byte-identical and its blob digest stays cached.
|
||||
path = jar.short_path
|
||||
if path.startswith("../"):
|
||||
path = path[3:]
|
||||
return path.replace("+", "_").replace("/", "_").replace("~", "_")
|
||||
|
||||
def _impl(ctx):
|
||||
info = ctx.attr.binary[JavaInfo]
|
||||
|
||||
app = []
|
||||
deps = []
|
||||
seen = {}
|
||||
for jar in sorted(info.transitive_runtime_jars.to_list(), key = lambda f: f.path):
|
||||
workspace = jar.owner.workspace_name if jar.owner else ""
|
||||
bucket = "app" if workspace == "" else "deps"
|
||||
out_name = _unique_name(jar)
|
||||
|
||||
key = bucket + "/" + out_name
|
||||
if key in seen:
|
||||
fail("jar_split: duplicate output name %r for %s and %s" % (
|
||||
key,
|
||||
seen[key],
|
||||
jar.path,
|
||||
))
|
||||
seen[key] = jar.path
|
||||
|
||||
link = ctx.actions.declare_file(ctx.label.name + "/" + key)
|
||||
ctx.actions.symlink(output = link, target_file = jar)
|
||||
(app if bucket == "app" else deps).append(link)
|
||||
|
||||
return [
|
||||
DefaultInfo(files = depset(app + deps)),
|
||||
OutputGroupInfo(app = depset(app), deps = depset(deps)),
|
||||
]
|
||||
|
||||
jar_split = rule(
|
||||
implementation = _impl,
|
||||
doc = "Partitions a JVM binary's transitive runtime jars into 'app' " +
|
||||
"(first-party, empty workspace) and 'deps' (third-party) output groups.",
|
||||
attrs = {
|
||||
"binary": attr.label(
|
||||
mandatory = True,
|
||||
providers = [[JavaInfo]],
|
||||
doc = "A jvm binary/library target whose runtime classpath to split.",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -42,16 +42,17 @@ Time-to-first-token (TTFT) was measured from request initiation to the first tex
|
||||
|
||||
### For Narrative Text Generation (Default)
|
||||
|
||||
**Gemini 2.5 Flash-Lite** is recommended as the default:
|
||||
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
|
||||
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
|
||||
- Quality is acceptable for short narrative snippets
|
||||
**Gemini 3.1 Flash-Lite** is recommended as the default:
|
||||
- Priced at $0.25/$1.50 per 1M input/output tokens
|
||||
- Significantly faster than 2.5 Flash-Lite on throughput and TTFT
|
||||
- Meaningfully smarter than 2.5 Flash-Lite, while remaining one of the cheapest options
|
||||
- Quality is more than sufficient for short narrative snippets
|
||||
|
||||
### Alternative Options
|
||||
|
||||
| Priority | Model | When to Use |
|
||||
|----------|-------|-------------|
|
||||
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
|
||||
| Speed + Cost | Gemini 3.1 Flash-Lite | Default for most use cases |
|
||||
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
|
||||
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
|
||||
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
|
||||
@@ -70,7 +71,7 @@ LLM settings can be changed at runtime via the admin console:
|
||||
1. Navigate to Admin Console → Settings
|
||||
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
|
||||
3. Change the corresponding model name setting:
|
||||
- `GeminiModelName` (default: gemini-2.5-flash-lite)
|
||||
- `GeminiModelName` (default: gemini-3.1-flash-lite-preview)
|
||||
- `OpenAiModelName` (default: gpt-4.1-mini)
|
||||
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Shardok AI Experiment Tooling Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Build a reusable workflow for Shardok AI battle baselines and scoring experiments. The goal is to
|
||||
turn the current one-off benchmark process into repeatable commands that extract real battles, run
|
||||
baseline and candidate configurations, capture detailed per-command traces, and write comparable
|
||||
reports.
|
||||
|
||||
This work starts with the target-aware archery scoring experiment: attacker `EXPERIMENTAL` against
|
||||
defender `STANDARD`.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add simulator trace support to `ai_battle_simulator_main`.
|
||||
- New flag: `--trace-jsonl=<path>`.
|
||||
- Emit one JSONL row per AI-selected command.
|
||||
- Include run label, config path, state file, phase, sequence, round, player, side, command type,
|
||||
actor unit, target coords, chosen index, available command count, search depth, commands
|
||||
evaluated, completion reason, forced commands posted afterward, and pre/post side troop totals.
|
||||
- Keep existing summary output unchanged.
|
||||
|
||||
- Add reusable benchmark tooling under
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/`.
|
||||
- `real_battle_suite.py`: shared helpers for reading `game.db`, materializing battle/state/result
|
||||
blobs, extracting configs, running simulator binaries, parsing summaries, and collecting
|
||||
real-game metadata.
|
||||
- `run_real_battle_experiment.py`: CLI orchestration for baseline/candidate runs.
|
||||
- Outputs: `summary.csv`, `comparison.csv`, `report.md`, generated configs, and per-run trace
|
||||
JSONL files.
|
||||
|
||||
- Add a clean EXPERIMENTAL switch for target-aware archery value.
|
||||
- Use a new experiment id.
|
||||
- Change only archery-availability scoring.
|
||||
- Replace the flat archery-possible value with a target-sensitive value based on the best enemy
|
||||
unit value.
|
||||
- Do not include the previous fear-value change from experiment `27`.
|
||||
|
||||
## Execution Plan
|
||||
|
||||
- Create a feature branch from fetched `origin/main`.
|
||||
- Commit this plan document first.
|
||||
- Implement simulator trace output and reusable benchmark tooling.
|
||||
- Add the target-aware archery-only EXPERIMENTAL switch.
|
||||
- Run the new tool first on battle `6248`, then on the all-40 saved-battle suite if local runtime is
|
||||
acceptable.
|
||||
- Compare baseline attacker `STANDARD` / defender `STANDARD` with candidate attacker
|
||||
`EXPERIMENTAL` / defender `STANDARD`.
|
||||
- Record the results under the existing benchmark results area and call out battle `6248` in the
|
||||
generated report.
|
||||
- Push the branch and create a PR after local validation.
|
||||
|
||||
## Tests
|
||||
|
||||
- Build:
|
||||
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_main`
|
||||
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_config_extractor`
|
||||
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_smoke_metadata`
|
||||
- Unit/integration:
|
||||
- `bazel test //src/test/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_test`
|
||||
- Add or extend a simulator test that verifies trace output is created and contains required
|
||||
command fields.
|
||||
- `python3 -m py_compile` on the new benchmark scripts.
|
||||
- Validation:
|
||||
- Run the new tool on battle `6248`.
|
||||
- Run the new tool on all 40 battles if feasible locally.
|
||||
- Run `bazel test //src/test/cpp/...` before push if local time allows; otherwise open the PR
|
||||
promptly and continue validation while CI starts.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "Experiment 3" means target-aware archery value from the previous recommendation list.
|
||||
- The first experiment compares attacker-only candidate behavior against unchanged defender
|
||||
`STANDARD`.
|
||||
- JSONL is the trace format because command diagnostics will evolve over time.
|
||||
- Python benchmark scripts follow the existing executable-script pattern rather than adding Python
|
||||
Bazel targets.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Shardok AI Scoring Benchmarks
|
||||
|
||||
The current STANDARD scoring experiment results are recorded in
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/experimental_results/`.
|
||||
They use 40 saved Shardok battles extracted from `game_389ffb1901903118.zip`.
|
||||
|
||||
The corrected all-40 run uses:
|
||||
|
||||
- `max_rounds = 31`
|
||||
- `random_seed = 1`
|
||||
- iterative-deepening timeout propagation fix from PR #6923
|
||||
- AI setup placement
|
||||
- forced single-option commands
|
||||
- defender scoring fixed to `STANDARD` for every row
|
||||
- archery/start-fire capability derived like production setup
|
||||
- per-unit flee capability preserved by real-battle extraction
|
||||
- zero-troop combat heroes preserved as live units
|
||||
- `NO_PROFESSION` treated as a valid hero profession state, not as "no hero"
|
||||
|
||||
Shardok siege battles do not have a draw result. If the attacker has not won after 31 rounds, the
|
||||
defender wins. The simulator records those cutoffs as `max_rounds_reached` defender wins.
|
||||
|
||||
All 40 saved battle payloads now produce valid simulator configs. A battle side can have zero
|
||||
remaining troops as long as it has a hero; this is common for `NO_PROFESSION` heroes with no
|
||||
commanded battalion.
|
||||
|
||||
An all-40 real-vs-sim comparison records whether the saved-game attacker/defender were human or AI
|
||||
players. Factions `3` and `4` are human players; all other factions in this save are AI players.
|
||||
The comparison uses broad winner side from the saved Shardok result payload, because exact replay
|
||||
is not available from the compact result rows. Real end-of-battle troop totals come from the
|
||||
all-factions Shardok `ActionResultView` stream's final `changed_player_totals`.
|
||||
|
||||
An AI-vs-AI smoke check also reruns a subset where neither side used faction `3` or `4`.
|
||||
|
||||
## Current Results
|
||||
|
||||
These artifacts are the current baseline for future scoring comparisons.
|
||||
|
||||
| Dataset | Valid battles | Invalid rows | Attacker wins | Defender wins | Notes |
|
||||
| --- | ---: | ---: | ---: | ---: | --- |
|
||||
| All-40 timeout-fix STANDARD baseline | 40 | 0 | 18 | 22 | `standard_scoring_all40_post_bugfix_baseline.*`; regenerated after fixing iterative-deepening timeout score propagation |
|
||||
| All-40 real saved game | 40 known | 0 unknown | 19 | 21 | `standard_scoring_all40_real_vs_sim.*`; sim matches 39/40 known winners |
|
||||
| AI-vs-AI smoke | 31 | 0 | 10 | 21 | 31/31 winner-side matches |
|
||||
|
||||
The remaining real-vs-sim winner-side miss is `11754` (real attacker, sim defender), which had an
|
||||
AI attacker but a human defender in the saved game. The real attacker won with `2119` troops
|
||||
remaining; the STANDARD-vs-STANDARD simulation replaces the human defender with AI and the defender
|
||||
wins with `1000` troops remaining, so this row is not a comparable AI-vs-AI smoke miss. Battle
|
||||
`6248`, previously the remaining AI-vs-AI miss, now matches as an attacker win after expected-impact
|
||||
archery scoring was promoted to STANDARD.
|
||||
|
||||
The closest six valid baseline rows are used for the scalar and behavior experiment batches. The
|
||||
single-run harness still has some nondeterminism even with `random_seed = 1`, so near-margin changes
|
||||
should be repeated before being promoted.
|
||||
|
||||
## Experiment Tooling
|
||||
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_real_battle_experiment.py`
|
||||
is the reusable real-battle experiment runner. It can read a saved-game zip or extracted `game.db`,
|
||||
generate baseline/candidate simulator configs, run both sides, and write `summary.csv`,
|
||||
`comparison.csv`, `report.md`, generated configs, and per-command trace JSONL files.
|
||||
|
||||
The repository keeps the current STANDARD baseline artifacts, but omits generated configs/traces
|
||||
for abandoned experiment batches. Regenerate those locally with the runner when a new comparison
|
||||
needs command-level traces.
|
||||
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_standard_baselines.py`
|
||||
regenerates the tracked STANDARD-vs-STANDARD baseline artifacts from a saved-game zip or extracted
|
||||
`game.db`, including the all-40 baseline, real-vs-sim comparison, and AI-vs-AI smoke files.
|
||||
|
||||
The simulator supports `--trace-jsonl=<path>` and `--run-label=<label>` for detailed command traces.
|
||||
Each row records the selected command, search depth, forced command count, and pre/post side troop
|
||||
totals.
|
||||
|
||||
Expected-impact archery replaced the old flat archery-position value in STANDARD. A unit that can
|
||||
shoot now gets half of the expected volley impact; a unit that is merely positioned for a future
|
||||
volley gets one quarter. Promoting that behavior flips `6248` to match the real AI-vs-AI attacker
|
||||
win without introducing an AI-vs-AI winner-side miss in the current saved-battle baseline.
|
||||
|
||||
The latest baseline refresh was generated after the iterative-deepening evaluator stopped letting
|
||||
timeout fallback scores compete with completed command evaluations. Future STANDARD-vs-EXPERIMENTAL
|
||||
comparisons should compare against these checked-in timeout-fix baseline artifacts.
|
||||
@@ -6,6 +6,16 @@ Be able to support a small (10-50 user) private alpha, including with strangers.
|
||||
|
||||
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
|
||||
|
||||
## Current priority order
|
||||
|
||||
1. Finish tutorial & first-session onboarding, especially the clear first-session goal and guided first scenario.
|
||||
2. Fix the basic Shardok AI issues that can make tactical battles feel broken or unfair.
|
||||
3. Publish a known issues doc so alpha testers do not repeatedly report the same rough edges.
|
||||
4. Verify the existing game-ended flow, then mark the win condition done if "all other factions defeated" works end-to-end.
|
||||
5. Add mid-game progression events where the King recognizes the player as they gain power.
|
||||
6. Add account-linking when provider switching or account recovery becomes important for alpha support.
|
||||
7. Leave terms of service and Windows code signing deferred until they matter for public release or broader distribution.
|
||||
|
||||
## Required
|
||||
|
||||
### Gameplay Productionization
|
||||
@@ -17,7 +27,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
|
||||
- [x] ~~Fix long disconnects on deployments~~
|
||||
- [x] Fix the Mac installer
|
||||
- [x] ~~Still not reconnecting after deployments~~
|
||||
- [ ] Notify about client updates, button to come directly back
|
||||
- [x] Notify about client updates, button to come directly back
|
||||
- [x] Generatedtext healing
|
||||
- [x] Kill outstanding shardok requests when game is deleted
|
||||
|
||||
@@ -51,7 +61,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
|
||||
|
||||
### Basic Gameplay
|
||||
|
||||
- [ ] Tutorial
|
||||
- [ ] Tutorial & first-session onboarding
|
||||
- [x] In the Your Warlord panel, say what the profession is
|
||||
- [x] ~~And separate panels for each profession when you encounter one~~
|
||||
- [x] ~~Command tutorial for each command the first time it's clicked~~
|
||||
@@ -62,17 +72,16 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
|
||||
- [x] ~~Time to swear brotherhood~~
|
||||
- [x] ~~When you get large, or~~
|
||||
- [x] ~~When you get a good candidate~~
|
||||
- [ ] Shardok tutorial!
|
||||
- [x] ~~Shardok tutorial!~~
|
||||
- [x] ~~Narrative hook in first few minutes - why should I care about my warlord?~~
|
||||
- [ ] Clear first-session goal ("try to capture your first province" or similar)
|
||||
- [x] ~~Early small victory to build momentum~~
|
||||
- [ ] Guided first scenario vs. overwhelming sandbox?
|
||||
- [ ] Basic Shardok AI stuff fixed
|
||||
- [ ] Lobby fixes
|
||||
- [x] ~~Lobby fixes~~
|
||||
- [ ] Have goals / ending
|
||||
- [ ] Win condition: all other factions defeated
|
||||
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
|
||||
- [ ] First-session onboarding (beyond mechanics tutorial)
|
||||
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
|
||||
- [ ] Clear first-session goal ("try to capture your first province" or similar)
|
||||
- [ ] Early small victory to build momentum
|
||||
- [ ] Guided first scenario vs. overwhelming sandbox?
|
||||
|
||||
## Nice to have
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# SQLite Action Result Decomposition
|
||||
|
||||
Follow-up to [SQLITE_HISTORY_DESIGN.md](SQLITE_HISTORY_DESIGN.md). This document plans the second wave of the SQLite migration: decomposing the `ActionResult` proto blob in `action_results.payload` into native relational tables, deleting the proto representation entirely for everything in scope.
|
||||
|
||||
## Status — option B adopted (updated 2026-05-17)
|
||||
|
||||
**This section supersedes the original plan below. Where the body conflicts with this section, this section wins.** The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.
|
||||
|
||||
The original plan (decompose every field into relational tables; "decision locked") was implemented for `ChangedProvince` first — ~37 child tables, ~1500 lines (PR #6721) — and then **rejected on cost/benefit**. We adopted **option B**: apply the "stopping rule" (originally carved out only for `Quest`) *consistently*. Shipped and merged as Phase 4.5b in **PR #6725**:
|
||||
|
||||
- Keep the per-entity **top-level table with scalar / entity-reference columns + indexes** (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
|
||||
- Store the **rest of the aggregate as one `<entity>_proto BLOB` column** on that row: lossless round-trip / replay, no child tables for the deep nesting.
|
||||
- Scalar columns are pure query denormalization; **the proto blob is authoritative**. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.
|
||||
|
||||
Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing `Quest`. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need **no SQL migration** (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).
|
||||
|
||||
## Refinement — "refined option B" for shallow entities (updated 2026-05-19)
|
||||
|
||||
**This section further supersedes the body and the Status section above where they conflict; it wins.** It records the pattern actually shipped for `ChangedHero` in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.
|
||||
|
||||
Option B as originally stated keeps **one `<entity>_proto BLOB`** per row for the entire non-scalar residual. For a *shallow* entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:
|
||||
|
||||
- **Tier 1 — scalar / entity-reference columns + indexes.** The queryable surface (deltas, touched entities, enum ordinals). Always present; unchanged from 4.5a.
|
||||
- **Tier 2 — typed columns for sealed value-oneofs.** A sealed choice over simple values (e.g. `ChangedHeroC`'s `loyalty` / `vigor` `StatChange`, subtypes `StatDelta`/`StatAbsolute`/`StatNoChange`) becomes a **pair of nullable columns** (`loyalty_delta` / `loyalty_absolute`, …). The *column name is the discriminator*; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No `kind` column.
|
||||
- **Tier 3 — per-row leaf-proto blobs in a child table.** A repeated/optional nested aggregate becomes a child table keyed by `(action_seq, entity_id, n)` storing the **leaf** proto's bytes one row per element (e.g. `action_changed_heroes_new_backstory_events` holding `EventForHeroBackstory` blobs) — *not* a whole-entity blob.
|
||||
|
||||
**Consequence (changes Decisions #1–#3 and 4.5i):** an entity fully covered by Tiers 1–3 has **no whole-entity blob**. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (`changed_hero.proto` / `ChangedHeroConverter`) become genuinely dead code, deletable in 4.5i. Only the **leaf** protos used as Tier-3 blob payloads (`event_for_hero_backstory.proto`) are retained as a wire format. The whole-entity `<entity>_proto BLOB` is now reserved for **deep** entities where Tiers 2–3 would be more work than value (`ChangedProvince`, the "new entity" snapshots).
|
||||
|
||||
Per-entity decision (supersedes Decisions #2): classify each entity as **all-scalar** (Tier 1 only, no blob — e.g. likely `ChangedFaction`), **refined** (Tiers 1–3, no whole-entity blob, proto deletable — `ChangedHero`), or **deep** (Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained — `ChangedProvince`, new-entity snapshots). Default to **refined** for shallow entities; fall back to **deep** only when the residual is too varied to be worth Tiers 2–3. Wherever the body or Status says "the proto blob is authoritative," for a *refined* entity read "the columns + per-leaf-proto blobs are authoritative."
|
||||
|
||||
## Why
|
||||
|
||||
The first wave (Phases 1–4) stores each action as a single proto blob in `action_results.payload`. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The `action_result_type`, `round_id`, and date columns we denormalized are the only handles SQL has into actions.
|
||||
|
||||
Two observations make full decomposition more attractive than the original design admitted:
|
||||
|
||||
1. **The proto exists for storage only.** 92 Scala files reference `ChangedProvince`, but exactly one imports the proto type — `ChangedProvinceConverter.scala`. Everything else (action handlers, AI, utils) operates on `ChangedProvinceC`. The proto is **not** in any client-facing RPC contract — it lives under `eagle.internal`. Same is true for `ChangedHero`, `ChangedFaction`, `ChangedBattalion`, `Notification`, `GeneratedTextRequest`, and `ActionResult` itself. Replacing proto serialization with SQL writes does **not** lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.
|
||||
|
||||
2. **`ActionResultC` has no polymorphism at the top level.** It's a 34-field flat struct — `Option`s and `Vector`s, but no `sealed trait` discriminator at the action level. Every action has the same shape; the `actionResultType` enum tells you *which fields* are populated. This means the entire structure is relationalizable without per-action-type tables.
|
||||
|
||||
The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:** `action_results.payload` (the whole-`ActionResult` blob) is gone by the end. Per-entity scalar / entity-reference columns make the *queryable* surface (deltas, touched entities) native SQL. **Under refined option B (see Status):** `internal/action_result.proto` and *deep* entities' sub-message protos (`changed_province.proto`, …) are retained as blob payloads; *refined* entities' protos (`changed_hero.proto`) are decomposed away and deleted, with only their Tier-3 leaf protos (`event_for_hero_backstory.proto`) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)
|
||||
|
||||
**Out:**
|
||||
- `state_snapshots.payload` — stays as proto-blob `GameState`. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
|
||||
- Shardok tables — already designed in Phase 3, no changes here. The shardok protos (`ShardokActionResult`, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
|
||||
- Client text — already SQLite-backed (`SqliteClientTextStore`).
|
||||
|
||||
## Principles
|
||||
|
||||
1. **One table per non-trivial aggregate type.** Every `Vector[X]` or `Option[X]` where `X` is a Scala case class with multiple fields gets its own table.
|
||||
2. **Vectors of primitives → join tables.** `Vector[Int]` becomes a two-column table: `(parent_key, value)`. No JSON arrays, no comma-separated strings — those defeat the point.
|
||||
3. **Optional aggregates → presence by row existence.** `Option[X]` for aggregate `X` is a row that may or may not exist with the matching parent key. The natural primary key on `(action_seq)` enforces 0-or-1-row semantics for `Option[X]` aggregates and `(action_seq, n)` for vectors.
|
||||
4. **Sealed traits get a discriminator column.** When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with `kind TEXT NOT NULL` + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
|
||||
5. **`ON DELETE CASCADE` everywhere.** Every child table has `FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE`. `truncateTo` becomes a single `DELETE FROM action_results WHERE action_seq >= ?` and the cascades do the rest.
|
||||
6. **Schema migrations are now real.** Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The `metadata` table's `schema_version` becomes load-bearing.
|
||||
7. **(Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it.** The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual *relational* representation to keep in sync.
|
||||
|
||||
## Top-level: `action_results`
|
||||
|
||||
The `payload BLOB` column goes away. Scalar fields become columns; aggregate fields move to related tables.
|
||||
|
||||
```sql
|
||||
CREATE TABLE action_results (
|
||||
action_seq INTEGER PRIMARY KEY,
|
||||
action_result_type INTEGER NOT NULL,
|
||||
-- existing scalar denormalizations
|
||||
round_id INTEGER NOT NULL,
|
||||
date_year INTEGER,
|
||||
date_month INTEGER,
|
||||
-- new scalar columns (formerly inside payload)
|
||||
acting_hero_id INTEGER,
|
||||
acting_faction_id INTEGER,
|
||||
province_id INTEGER,
|
||||
province_id_acted INTEGER,
|
||||
new_round_phase INTEGER,
|
||||
new_round_id INTEGER,
|
||||
last_command_type_for_acting_province INTEGER,
|
||||
resolved_battle TEXT,
|
||||
new_victor_faction_id INTEGER,
|
||||
game_ended INTEGER, -- 0/1/NULL
|
||||
new_random_seed INTEGER,
|
||||
new_game_type INTEGER
|
||||
);
|
||||
|
||||
-- Vectors of bare IDs become join tables:
|
||||
CREATE TABLE action_destroyed_battalion_ids (
|
||||
action_seq INTEGER NOT NULL,
|
||||
battalion_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (action_seq, battalion_id),
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE action_removed_hero_ids (
|
||||
action_seq INTEGER NOT NULL,
|
||||
hero_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (action_seq, hero_id),
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE action_removed_faction_ids (...);
|
||||
CREATE TABLE action_affected_faction_ids (...);
|
||||
|
||||
-- Singleton optional aggregates: a row exists if and only if Option is Some.
|
||||
CREATE TABLE action_new_battles (
|
||||
action_seq INTEGER PRIMARY KEY,
|
||||
-- ShardokBattle fields here, columns or sub-tables as needed
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE action_new_chronicle_entries (action_seq INTEGER PRIMARY KEY, ...);
|
||||
CREATE TABLE action_new_eagle_map_info (action_seq INTEGER PRIMARY KEY, ...);
|
||||
```
|
||||
|
||||
Each `Vector[Aggregate]` field on `ActionResultC` gets its own table (covered below).
|
||||
|
||||
### Indexes
|
||||
|
||||
We keep the existing indexes (`round_id`, `(date_year, date_month)`) and add a few that obvious queries need:
|
||||
|
||||
- `idx_action_results_acting_hero_id`
|
||||
- `idx_action_results_acting_faction_id`
|
||||
- `idx_action_results_province_id`
|
||||
|
||||
Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:
|
||||
|
||||
- `idx_action_changed_provinces_province_id`
|
||||
- `idx_action_changed_heroes_hero_id`
|
||||
- `idx_action_changed_faction_faction_id`
|
||||
- `idx_action_changed_battalions_battalion_id`
|
||||
|
||||
## Changed-entity tables (four parallel structures)
|
||||
|
||||
### `action_changed_provinces`
|
||||
|
||||
`ChangedProvinceC` has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on `action_changed_provinces`; aggregate fields become child tables.
|
||||
|
||||
```sql
|
||||
CREATE TABLE action_changed_provinces (
|
||||
action_seq INTEGER NOT NULL,
|
||||
province_id INTEGER NOT NULL,
|
||||
-- resource changes
|
||||
gold_delta INTEGER,
|
||||
food_delta INTEGER,
|
||||
-- stat changes
|
||||
new_price_index REAL,
|
||||
economy_delta REAL,
|
||||
agriculture_delta REAL,
|
||||
infrastructure_delta REAL,
|
||||
economy_devastation_delta REAL,
|
||||
agriculture_devastation_delta REAL,
|
||||
infrastructure_devastation_delta REAL,
|
||||
support_delta REAL,
|
||||
-- round properties
|
||||
set_has_acted INTEGER, -- 0/1/NULL
|
||||
set_ruler_is_traveling INTEGER,
|
||||
-- ruling faction changes
|
||||
clear_ruling_faction_id INTEGER NOT NULL DEFAULT 0,
|
||||
new_ruling_faction_id INTEGER,
|
||||
-- misc scalar fields
|
||||
new_locked_improvement_kind TEXT, -- 'none', 'new'
|
||||
new_locked_improvement_value INTEGER, -- ImprovementType if kind='new'
|
||||
new_province_orders INTEGER, -- ProvinceOrderType enum
|
||||
clear_defending_army INTEGER NOT NULL DEFAULT 0,
|
||||
cleared_pending_conquest_info INTEGER NOT NULL DEFAULT 0,
|
||||
removed_deferred_change_index INTEGER,
|
||||
PRIMARY KEY (action_seq, province_id),
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_action_changed_provinces_province_id
|
||||
ON action_changed_provinces(province_id);
|
||||
```
|
||||
|
||||
The vector and optional-aggregate fields of `ChangedProvinceC` become per-aggregate child tables, all keyed by `(action_seq, province_id)`:
|
||||
|
||||
- `action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...)` — one row per added hero, with `UnaffiliatedHero` fields as columns
|
||||
- `action_changed_provinces_changed_unaffiliated_heroes(...)` — same shape
|
||||
- `action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)` — join table for ID vector
|
||||
- `action_changed_provinces_new_captured_heroes(...)` — `CapturedHero` fields
|
||||
- `action_changed_provinces_recruitment_attempted_captured_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_removed_captured_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_new_ruling_faction_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_removed_ruling_faction_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_new_battalion_ids(...)` — join table
|
||||
- `action_changed_provinces_removed_battalion_ids(...)` — join table
|
||||
- `action_changed_provinces_new_incoming_armies(...)` — `MovingArmy` decomposition
|
||||
- `action_changed_provinces_removed_incoming_army_ids(...)` — join table
|
||||
- `action_changed_provinces_new_withdrawing_armies(...)` — `MovingArmy` decomposition
|
||||
- `action_changed_provinces_removed_withdrawing_army_ids(...)` — join table
|
||||
- `action_changed_provinces_new_hostile_armies(...)` — `HostileArmyGroup` decomposition
|
||||
- `action_changed_provinces_removed_hostile_army_faction_ids(...)` — join table
|
||||
- `action_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status)` — small flat aggregate
|
||||
- `action_changed_provinces_new_defending_army` — `Option[Army]` (singleton row if present)
|
||||
- `action_changed_provinces_new_incoming_shipments(...)` — `MovingSupplies` decomposition
|
||||
- `action_changed_provinces_removed_incoming_shipment_ids(...)` — join table
|
||||
- `action_changed_provinces_new_incoming_end_turn_actions(...)` — `IncomingEndTurnAction` decomposition
|
||||
- `action_changed_provinces_removed_incoming_end_turn_actions(...)` — same shape (these are values, not IDs)
|
||||
- `action_changed_provinces_new_battle_revelations(...)` — `BattleRevelation` decomposition
|
||||
- `action_changed_provinces_removed_battle_revelations(...)` — same shape
|
||||
- `action_changed_provinces_new_pending_conquest_info` — `Option[PendingConquestInfo]` (singleton row)
|
||||
- `action_changed_provinces_new_province_events` — `Vector[ProvinceEvent]`
|
||||
- `action_changed_provinces_new_deferred_change` — `Option[DeferredChange]` (singleton, with discriminator since `DeferredChangeT` is sealed)
|
||||
|
||||
That's a lot of tables — ~25 just under `ChangedProvince`. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, `n` (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.
|
||||
|
||||
### `action_changed_heroes`, `action_changed_factions`, `action_changed_battalions`
|
||||
|
||||
Same pattern. Each gets its top-level table keyed by `(action_seq, entity_id)`, plus child tables for nested aggregates and ID vectors. Indexes on the entity id (`hero_id`, `faction_id`, `battalion_id`) so cross-game queries are fast.
|
||||
|
||||
`ChangedHeroC` is a *refined* entity (see "Refinement" in Status): no whole-entity blob. Its sealed `StatChange` (`StatDelta`/`StatAbsolute`/`StatNoChange`) `loyalty` and `vigor` oneofs are Tier-2 column pairs (`loyalty_delta`/`loyalty_absolute`, `vigor_delta`/`vigor_absolute`; column name discriminates, both-NULL ⇒ `StatNoChange`). Its `newBackstoryEvents: Vector[EventForHeroBackstory]` is Tier-3: per-event `EventForHeroBackstory` blobs, one row each, in `action_changed_heroes_new_backstory_events`. Columns + per-event blobs round-trip losslessly, so `changed_hero.proto`/`ChangedHeroConverter` become deletable (4.5i) and only `event_for_hero_backstory.proto` is retained as a blob format.
|
||||
|
||||
## "New" entity tables
|
||||
|
||||
`ActionResultC.newProvinces: Vector[ProvinceT]`, `newHeroes`, `newFactions`, `newBattalions`. These are *full snapshots* of new entities at creation.
|
||||
|
||||
These are bigger and recursive: `ProvinceT` itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing `state_snapshots` blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in `ActionResultC`, which we're decomposing.
|
||||
|
||||
**Decision**: go fully relational here too. Each "new entity" gets its top-level table (`action_new_provinces` etc.) keyed by `(action_seq, entity_id)`, with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the `changed_*` family (preferred where the field shape is identical, e.g., `MovingArmy`, `UnaffiliatedHero`) or get their own (where they diverge).
|
||||
|
||||
This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.
|
||||
|
||||
## Notifications and generated text requests
|
||||
|
||||
```sql
|
||||
CREATE TABLE action_new_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
|
||||
CREATE TABLE action_removed_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
|
||||
CREATE TABLE action_new_generated_text_requests (...);
|
||||
CREATE TABLE action_client_text_visibility_extensions (...);
|
||||
```
|
||||
|
||||
`NotificationT` and `GeneratedTextRequestT` are both sealed traits. Use the discriminator-column pattern: `kind TEXT NOT NULL` plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.
|
||||
|
||||
## Schema evolution
|
||||
|
||||
We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.
|
||||
|
||||
Conventions:
|
||||
- `metadata['schema_version']` is a small integer, currently `1`.
|
||||
- A `Migrations` object holds an ordered `Vector[(Int, Connection => Unit)]` of migration steps.
|
||||
- On `SqliteHistory.loaded`, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
|
||||
- New games start at the latest version.
|
||||
- Migration steps are append-only — never edit an existing step.
|
||||
|
||||
This is the standard Rails / Flyway / etc. pattern, scaled down to one file.
|
||||
|
||||
## Read path: reconstructing `ActionResultC`
|
||||
|
||||
The current pattern:
|
||||
```scala
|
||||
val proto = ActionResult.parseFrom(payloadBlob)
|
||||
val scala = ActionResultProtoConverter.fromProto(proto)
|
||||
```
|
||||
|
||||
The new pattern:
|
||||
```scala
|
||||
val scala = ActionResultDbReader.read(connection, actionSeq) // assembles from action_results + child tables
|
||||
```
|
||||
|
||||
`ActionResultDbReader.read` is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.
|
||||
|
||||
For bulk reads (`since`, `all`, `sinceDate`, etc.), the existing `replayFrom` helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting `ActionResultC` instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.
|
||||
|
||||
## Write path
|
||||
|
||||
`withNewResults` becomes:
|
||||
1. INSERT into `action_results` (scalar fields).
|
||||
2. For each non-empty aggregate: INSERT N rows into the corresponding child table.
|
||||
|
||||
All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 0–3 provinces, 0–5 heroes, 0–10 changed entities total, plus 0–2 notifications. So the typical insert is ~5–20 rows. With WAL and a single per-batch transaction, this is cheap.
|
||||
|
||||
## Replay performance
|
||||
|
||||
Today's `replayFrom` parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100–500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.
|
||||
|
||||
## Deleting the proto
|
||||
|
||||
> **Superseded by refined option B (see Status).** Whole-`ActionResult` `payload` is removed in 4.5h. After that it depends on per-entity classification: *refined* entities' per-entity sub-message protos + converters (e.g. `changed_hero.proto`) **do** become dead code and are deleted in 4.5i; *deep* entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`), and `Quest` are *retained*. The original full-deletion plan below is kept for historical context.
|
||||
|
||||
Once `SqliteHistory` writes and reads exclusively from the new tables:
|
||||
|
||||
1. Delete `ActionResultProtoConverter.scala`, the `Changed{Province,Hero,Faction,Battalion}Converter.scala` files, the `Notification`/`GeneratedTextRequest`/etc. converters.
|
||||
2. Delete the proto definitions: `action_result.proto`, `changed_province.proto`, `changed_hero.proto`, `changed_faction.proto`, `notification.proto`, `generated_text_request.proto`, anything else in this scope.
|
||||
3. Delete the corresponding `_scala_proto` Bazel targets.
|
||||
4. Gazelle should drop the unused deps from downstream targets.
|
||||
|
||||
This is satisfying but should land *last*, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.
|
||||
|
||||
## Sequencing
|
||||
|
||||
**Phase 4** (cutover) lands as planned: `GamesManager` swaps to `SqliteHistory`, save dirs get nuked, action data is the single proto-blob `payload`. Days of work, validates the lifecycle integration without conflating with schema design.
|
||||
|
||||
**Phase 4.5** (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. **Re-planned for option B (see Status):** each entity gets the 4.5a scalar/entity-ref columns + a `<entity>_proto BLOB`; no child-table explosion. Each decomposition PR splits into `.1` (schema + backfill over existing `payload`) and `.2` (live `withNewResults` write path + read path), per the precedent set by 4.5b.
|
||||
|
||||
| PR | Work | State |
|
||||
|---|---|---|
|
||||
| 4.5a | Schema-migration scaffolding + four changed-entity top-level tables (scalar columns) | ✅ merged |
|
||||
| 4.5b | `ChangedProvince`: scalar columns + `changed_province_proto BLOB` + v3 backfill (option B, #6725) | ✅ merged |
|
||||
| 4.5c.1 | `ChangedHero` (**refined option B**, see Status): 4.5a scalars + loyalty/vigor Tier-2 column pairs + `newBackstoryEvents` as per-event `EventForHeroBackstory` Tier-3 blobs in `action_changed_heroes_new_backstory_events`; **no whole-entity blob** — `changed_hero.proto`/`ChangedHeroConverter` become deletable, `event_for_hero_backstory.proto` retained. Schema v4 + backfill over existing `payload` (#6727) | ✅ merged |
|
||||
| 4.5c.2 | `ChangedHero`: live `withNewResults` write path + read path | planned |
|
||||
| 4.5d | `ChangedFaction`: same pattern (mostly scalar already; small/empty blob — see Decisions #2) | planned |
|
||||
| 4.5e | `ChangedBattalion`: `changed_battalion_proto BLOB` (sole field is the contained `BattalionT`) + battalion_id index from 4.5a | planned |
|
||||
| 4.5f | "New entity" vectors (`newProvinces`/`newHeroes`/`newFactions`/`newBattalions`): one table per type keyed by `(action_seq, entity_id)` + a couple of queryable handles + `<entity>_proto BLOB`. Biggest win for B — these are the deepest snapshots | planned |
|
||||
| 4.5g | Notifications + generated text requests: `(action_seq, n, kind, <entity>_proto BLOB)` — `kind` discriminator queryable, payload blobbed (quest-hybrid shape) | planned |
|
||||
| 4.5h | Top-level `ActionResultC` scalar/entity-ref columns on `action_results` (acting_hero_id, acting_faction_id, province_id, …) + **drop `payload BLOB`**. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) | planned |
|
||||
| 4.5i | Delete proto files/targets that became dead code. *Refined* entities' per-entity protos + converters (e.g. `changed_hero.proto`/`ChangedHeroConverter`) **are deleted** here. *Deep* entities' protos + leaf Tier-3 protos (e.g. `event_for_hero_backstory.proto`) + `Quest` are **retained** as blob payloads. No longer near-nothing — scope scales with how many entities went *refined* | planned |
|
||||
|
||||
Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly **1 week**, not 3–4.
|
||||
|
||||
After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.
|
||||
|
||||
## What we gain at the end
|
||||
|
||||
- **Single source of truth.** Action data lives in tables; no proto representation for the things in scope.
|
||||
- **Native SQL analytics.** Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
|
||||
- **Inspectable saves.** `sqlite3 game.db` shows readable data. Debugging is normal SQL work, not "parse this binary blob."
|
||||
- **Cleaner replay path.** No proto parsing during replay. The `ActionResultProtoConverter` (currently called per replayed action in `replayFrom`) goes away entirely.
|
||||
- **Less code.** ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
|
||||
- **Schema migrations as a first-class concept.** Future field additions are SQL migrations, which is the right shape for a system that owns its storage.
|
||||
|
||||
## What we lose
|
||||
|
||||
- **The "edit one proto" workflow** for adding fields. Adding `newFooDelta: Double` to `ChangedProvinceC` is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
|
||||
- **A safety net for unrecognized fields.** Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.
|
||||
|
||||
## Decisions locked
|
||||
|
||||
1. **Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule).** Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity `<entity>_proto BLOB` is used **only for deep entities** where Tiers 2–3 cost more than they're worth. No child-table explosion for deep aggregates.
|
||||
2. **Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed").** *All-scalar* → Tier 1 only, no blob (likely `ChangedFaction`). *Refined* → Tiers 1–3, no whole-entity blob, per-entity proto deletable (`ChangedHero`). *Deep* → Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained (`ChangedProvince`, new-entity snapshots). Default to *refined* for shallow entities; fall back to *deep* only when the residual is too varied for Tiers 2–3.
|
||||
3. **What's retained vs. deleted depends on the classification.** `action_results.payload` (the whole-`ActionResult` blob) still goes away in 4.5h. *Refined* entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the **leaf** protos they use as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`) are retained. *Deep* entities' protos + `Quest` stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
|
||||
4. **Sub-phase ordering** as in the Sequencing table; each decomposition PR splits into `.1` (schema + backfill) and `.2` (live write + read).
|
||||
5. **Phase 4 landed separately, before 4.5a** (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.
|
||||
@@ -0,0 +1,291 @@
|
||||
# SQLite Game History Design
|
||||
|
||||
## Why
|
||||
|
||||
The current persistence layer is file-based: action results are chunked into `.e0a` files, individual results spill to `.e0i` files for crash recovery, state snapshots live in `.e0s` files, and a `directory.e0i` index tracks chunks. This shape has produced three workarounds in recent history:
|
||||
|
||||
1. A `games.e0es` cache (PR #6706) to avoid re-reading the running-games list.
|
||||
2. Dual-storage in `PersistedActionResult` (proto bytes + parsed proto) to amortize serialization across save flushes.
|
||||
3. Lazy `gameState` (PR #6692) to avoid eager proto conversion on every replayed step, after PR #6691 fixed the `stateAfter` OOM that materialized ~4500 `PersistedActionResult` instances during a stale-LLM-replay path.
|
||||
|
||||
Each is a workaround for the same root cause: file-based random access is expensive, so we cache aggressively, and the cache layers are fragile (one misplaced `.map(_.gameState)` reintroduces the OOM).
|
||||
|
||||
The alternative — SQLite as a per-game container, with proto bytes stored as BLOB columns indexed by action sequence number — gives us random access by construction, removes the need for all three workarounds, and makes idle-game eviction cheap (rehydrate from the DB instead of reparsing files). The pattern is already in production here: `SqliteClientTextStore` does exactly this for the client text cache. We extend the same pattern to game history.
|
||||
|
||||
## Goal & non-goals
|
||||
|
||||
**In scope:**
|
||||
- Per-game SQLite container (`game.db`) holding action history, state snapshots, and shardok results.
|
||||
- A new `SqliteHistory` class implementing the existing `FullGameHistory` trait, drop-in replacement for `PersistedHistory` at the call site.
|
||||
|
||||
**Out of scope (deferred or rejected):**
|
||||
- **Migration of existing saves.** Pre-alpha there are only two users and a handful of test games. Cheaper to nuke the existing save directories at cutover than to write and verify a migration importer. New games created post-cutover land in `game.db` directly; existing games are gone.
|
||||
- The top-level `games.e0es` running-games registry. Stays as-is; its cache (PR #6706) already works.
|
||||
- Idle-game eviction. Enabled by this work but lands in a follow-up phase after `SqliteHistory` is the authoritative path.
|
||||
- A shared cross-game analytics DB. Per-game design supports ad-hoc cross-game queries via iterate-and-aggregate; build the centralized analytics DB only if/when those queries become hot.
|
||||
|
||||
## File layout
|
||||
|
||||
`game.db` lives in the existing per-game save directory, alongside `text_store.db`:
|
||||
|
||||
```
|
||||
${EAGLE_SAVE_DIR}/${gameIdHex}/
|
||||
├── game.db # NEW — action history, snapshots, shardok results
|
||||
└── text_store.db # existing — client text (SqliteClientTextStore)
|
||||
```
|
||||
|
||||
Existing `.e0a` / `.e0s` / `.e0i` / `directory.e0i` files do not coexist with `game.db`: at cutover we nuke save directories one time. There is no migration path and no legacy-file fallback.
|
||||
|
||||
### Why `game.db` and `text_store.db` are separate files (not tables in one DB)
|
||||
|
||||
It's tempting to combine them — one file per game, one connection per game, one cloud-upload story. The reason not to: **writer-lock contention.** SQLite serializes writers per-database file. `SqliteClientTextStore` writes frequently during LLM streaming (one `UPDATE` per token append on `texts.text`). `SqliteHistory` writes per-action during turn commits. Combining them means an in-flight LLM token append can block a turn commit, or vice versa. Separate DBs have separate writer locks and never contend. Subsystem ownership (each package owning its own schema, independent migration paths if the schemas evolve) is a bonus.
|
||||
|
||||
`Persister` integration follows the `SqliteClientTextStore` pattern: `game.db` is uploaded to / downloaded from cloud storage as a single opaque blob, keyed by the filename. On first load, if `game.db` is missing locally, try `persister.retrieveAsStream("game.db")`. If both are missing, this is a new game and we create an empty DB.
|
||||
|
||||
## Schema
|
||||
|
||||
```sql
|
||||
-- Action results, one row per action_result_index.
|
||||
CREATE TABLE action_results (
|
||||
action_seq INTEGER PRIMARY KEY, -- 0-based, dense, never sparse
|
||||
action_result_type INTEGER NOT NULL, -- denormalized for filtering
|
||||
round_id INTEGER NOT NULL, -- denormalized for recentResultsForRound
|
||||
date_year INTEGER, -- denormalized for sinceDate; NULL pre-game-start
|
||||
date_month INTEGER, -- denormalized for sinceDate; NULL pre-game-start
|
||||
payload BLOB NOT NULL -- ActionResult proto bytes
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_action_results_round ON action_results(round_id);
|
||||
CREATE INDEX idx_action_results_date ON action_results(date_year, date_month);
|
||||
-- action_seq is the PK so no index needed there.
|
||||
|
||||
-- State snapshots at chunk boundaries (and the starting state at seq 0).
|
||||
-- boundary_action_seq is the action_seq AFTER which the snapshot reflects state.
|
||||
-- The starting state lives at boundary_action_seq = 0 (state before any actions).
|
||||
CREATE TABLE state_snapshots (
|
||||
boundary_action_seq INTEGER PRIMARY KEY,
|
||||
payload BLOB NOT NULL -- GameState proto bytes
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-battle results.
|
||||
CREATE TABLE shardok_results (
|
||||
shardok_game_id TEXT NOT NULL,
|
||||
action_seq INTEGER NOT NULL, -- 0-based within this shardok game
|
||||
payload BLOB NOT NULL, -- ShardokActionResult proto bytes
|
||||
PRIMARY KEY (shardok_game_id, action_seq)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-game state (one row per shardok_game_id).
|
||||
CREATE TABLE shardok_state (
|
||||
shardok_game_id TEXT PRIMARY KEY,
|
||||
game_state BLOB NOT NULL, -- ShardokGameState proto bytes
|
||||
last_eagle_round_id INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-player results (filtered ActionResultView per faction).
|
||||
CREATE TABLE shardok_player_results (
|
||||
shardok_game_id TEXT NOT NULL,
|
||||
faction_id INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
payload BLOB NOT NULL, -- ShardokActionResultView proto bytes
|
||||
PRIMARY KEY (shardok_game_id, faction_id, seq)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-player available commands (latest only, keyed by game + faction).
|
||||
CREATE TABLE shardok_player_commands (
|
||||
shardok_game_id TEXT NOT NULL,
|
||||
faction_id INTEGER NOT NULL,
|
||||
payload BLOB, -- ShardokAvailableCommands proto bytes; NULL = no commands
|
||||
PRIMARY KEY (shardok_game_id, faction_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Schema version and starting state.
|
||||
CREATE TABLE metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value BLOB NOT NULL
|
||||
);
|
||||
-- Seeded with: schema_version=1
|
||||
```
|
||||
|
||||
### Column rationale
|
||||
|
||||
- **`action_seq`** as `INTEGER PRIMARY KEY` (the SQLite rowid alias) — densest possible storage and no separate index; range scans for `since(start)` are O(log n + result count).
|
||||
- **`WITHOUT ROWID`** on tables with synthetic keys to skip the implicit rowid column.
|
||||
- **Denormalized `action_result_type`, `round_id`, `date_year`, `date_month`** — these are the predicates the existing read paths use (`recentResultsForRound`, `sinceDate`). Keeping them as columns avoids parsing the proto blob to filter.
|
||||
- **No `created_at` timestamp** — wall-clock time isn't queried by the History trait, and game date (year/month) is what matters semantically.
|
||||
- **`payload BLOB`** — the proto bytes are the source of truth for the structured action result. Polymorphic action types make a relational schema painful for limited gain; the denormalized columns above cover the queries we need.
|
||||
- **Snapshots keyed by `boundary_action_seq`** — `stateAfter(N)` finds `MAX(boundary_action_seq) WHERE boundary_action_seq <= N`, returns that snapshot, replays forward `N - boundary_action_seq` actions. Snapshot at `0` is the game's starting state.
|
||||
|
||||
### Snapshot strategy
|
||||
|
||||
Match the existing `resultsPerSaveFile = 25` boundary: write a `state_snapshots` row every 25 actions. That gives the same replay-window cost as the current chunk-file design (`stateAfter(N)` replays at most 25 actions to reach an arbitrary point), and matches the cadence developers are already calibrated to.
|
||||
|
||||
Snapshots are GameState proto bytes, identical in shape to today's chunk-file `startingState`. The migration importer derives them directly from the chunk files. New games write a snapshot after every 25th `withNewResults` action.
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
Mirror `SqliteClientTextStore`:
|
||||
|
||||
- One `Connection` per loaded game, opened when `GamesManager` loads the game, closed when the game is evicted (future eviction work) or the server shuts down.
|
||||
- `Class.forName("org.sqlite.JDBC")` + `DriverManager.getConnection("jdbc:sqlite:${path}")` on open.
|
||||
- `PRAGMA journal_mode = WAL` on every connection open. WAL gives us crash-safe writes and lets readers proceed concurrently with the single writer — important because gRPC stream readers (humanPlayerClientConnectionState) query history mid-turn.
|
||||
- `PRAGMA synchronous = NORMAL` (the WAL-recommended setting; durability is preserved through WAL checkpoint).
|
||||
- `PRAGMA foreign_keys = ON` (defensive; we have no FKs today but cheap to enable).
|
||||
- Auto-commit on by default. Transactions explicitly opened for `withNewResults` (batch of action inserts + optional snapshot) and `truncateTo` (deletes across all tables).
|
||||
|
||||
### Threading
|
||||
|
||||
The current `PersistedHistory` is an immutable case class; `withNewResults` returns a new instance. `SqliteHistory` cannot be pure-immutable (the DB is mutable state) but should present the same interface: methods that "change" the history return `this` after a successful write. The underlying `Connection` is shared.
|
||||
|
||||
JDBC `Connection` is not thread-safe in general; SQLite's JDBC driver serializes operations per-connection. Existing call sites already serialize writes through the `EngineApplier` flow, so single-threaded write access is preserved. Reads from gRPC stream readers can use the same connection — SQLite serializes them transparently, and WAL prevents read-write blocking.
|
||||
|
||||
## Read paths
|
||||
|
||||
How each `FullGameHistory` method maps to SQL:
|
||||
|
||||
| Method | Query |
|
||||
|---|---|
|
||||
| `count` | `SELECT COALESCE(MAX(action_seq), -1) + 1 FROM action_results` (cached as a counter after the first read) |
|
||||
| `last` | `SELECT payload FROM action_results ORDER BY action_seq DESC LIMIT 1` + state from `stateAfter(count)` |
|
||||
| `all` | `SELECT payload FROM action_results ORDER BY action_seq` — used by `GameAdminServiceImpl.getActionDetail` to fetch one action by index. See follow-up note below. |
|
||||
| `since(start)` | `SELECT payload FROM action_results WHERE action_seq >= ? ORDER BY action_seq` |
|
||||
| `sinceDate(date)` | `SELECT payload FROM action_results WHERE (date_year, date_month) >= (?, ?) ORDER BY action_seq` |
|
||||
| `recentResultsForRound(round, pred)` | `SELECT payload FROM action_results WHERE round_id = ? AND action_seq > ? ORDER BY action_seq` where `?` is the cutoff matching current `recentHistory` semantics (last N actions, or all actions for the current round) |
|
||||
| `stateAfter(N)` | Find latest snapshot ≤ N, replay forward via `replayApplier` (same logic as `replayScalaOnlyToState`) |
|
||||
|
||||
For methods that return `Vector[ActionResultWithResultingState]` (with resulting state per row): we **do not** materialize per-row gameStates. Instead, fold the actions through `replayApplier` starting from the latest snapshot ≤ start, producing the states on the fly. This matches what `formAwrs` does today, with one critical difference: no `PersistedActionResult` wrapper is allocated, and no proto-conversion is performed per step. This is the same shape as `replayScalaOnlyToState`, generalized to produce intermediate states.
|
||||
|
||||
### `all()` follow-up
|
||||
|
||||
`GameAdminServiceImpl.getActionDetail` is the one production caller. It fetches a single action by index from the full vector. Either of these is cheaper than materializing the whole history:
|
||||
|
||||
- Replace the call site with `history.since(index).headOption`.
|
||||
- Add a new `actionAt(index): Option[ActionResultWithResultingState]` method and drop `all()` from the trait entirely.
|
||||
|
||||
`SqliteHistory.all` will work — it's just `SELECT * ORDER BY action_seq` — but it's expensive (materializes the full history into memory), so we should switch the admin caller in a small follow-up PR. Not blocking the SQLite work.
|
||||
|
||||
### `recentResultsForRound` semantics
|
||||
|
||||
Today this filters `recentHistory` (the in-memory tail) by `roundId`. In SQLite there's no in-memory/persisted split — all results live in the DB. The semantics shift slightly: return all results matching `round_id` after a configurable cutoff. The cutoff should match today's behavior (results since the start of the current round, or some bounded recent window). Default to "all results with the given `round_id`," which is correct as long as `round_id` uniquely identifies a round across the game's history (it does, per the current `RoundId` model).
|
||||
|
||||
## Write paths
|
||||
|
||||
### `withNewResults(newResults)`
|
||||
|
||||
In a transaction:
|
||||
1. `INSERT INTO action_results (action_seq, action_result_type, round_id, date_year, date_month, payload) VALUES ...` — one row per new result. Use `addBatch()` for multiple.
|
||||
2. For each new result whose `action_seq % 25 == 0` (snapshot boundary), `INSERT INTO state_snapshots(boundary_action_seq, payload) VALUES (?, ?)` with the GameState proto bytes.
|
||||
3. Commit.
|
||||
|
||||
The denormalized columns (`action_result_type`, `round_id`, `date_year`, `date_month`) are extracted from the action's resulting state at insert time. They are immutable once written; if the schema interpretation changes, a migration is required.
|
||||
|
||||
The "individual result for crash recovery" pattern (`.e0i` files) is replaced by: the action result is durably written when the transaction commits. WAL gives us atomicity per transaction. No separate crash-recovery file is needed.
|
||||
|
||||
### `saveNow`
|
||||
|
||||
Becomes a no-op in normal operation — writes are already durable per `withNewResults` commit. We keep the method on the trait for API compatibility but the implementation just returns `this`. (We could call `PRAGMA wal_checkpoint(TRUNCATE)` here to roll the WAL into the main DB file, useful before cloud upload; defer this until we measure WAL growth.)
|
||||
|
||||
### `truncateTo(targetActionCount)`
|
||||
|
||||
In a transaction:
|
||||
1. `DELETE FROM action_results WHERE action_seq >= ?`
|
||||
2. `DELETE FROM state_snapshots WHERE boundary_action_seq > ?`
|
||||
3. Shardok cleanup: `DELETE FROM shardok_results / shardok_state / shardok_player_results / shardok_player_commands WHERE shardok_game_id NOT IN (...)` (the set of battles still outstanding at the truncate point; mirrors `deleteOrphanedShardokFiles`).
|
||||
4. Commit.
|
||||
|
||||
`truncateTo(0)` resets the game; everything after the starting-state snapshot is deleted.
|
||||
|
||||
## Shardok results
|
||||
|
||||
The shardok subsystem currently lives in `.e0s` files (one per battle, full per-battle state). It's loaded selectively at game-load time, only for outstanding battles (see `PersistedHistory.apply` line 248-256).
|
||||
|
||||
Moving to SQLite, the four shardok tables above capture:
|
||||
- `shardok_results` — the per-battle result stream
|
||||
- `shardok_state` — current per-battle state (one row per battle)
|
||||
- `shardok_player_results` — per-faction filtered views
|
||||
- `shardok_player_commands` — current available commands per faction
|
||||
|
||||
`withNewShardokResults` writes to all four in a transaction. `shardokCount`, `shardokGameState`, etc., become single-row indexed lookups.
|
||||
|
||||
The selective-load optimization disappears with SQLite: we don't proactively read anything; queries hit the DB on demand. The "load only outstanding battles" logic is replaced by "query by `shardok_game_id` when needed."
|
||||
|
||||
## Crash recovery
|
||||
|
||||
WAL replaces the `.e0i` individual-result-file mechanism. On startup:
|
||||
- WAL is automatically replayed by SQLite if the previous shutdown was unclean. No application code needed.
|
||||
- A successful commit means durable; an interrupted commit means rolled back. No half-written state visible.
|
||||
|
||||
The current "orphaned individual results on load" path (`loadIndividualResults` in `PersistedHistory.apply`) is gone.
|
||||
|
||||
## Cutover
|
||||
|
||||
No migration path. At cutover:
|
||||
|
||||
1. Stop the server.
|
||||
2. Nuke the contents of `${EAGLE_SAVE_DIR}` (and `${EAGLE_ARCHIVE_DIR}` if there's anything there).
|
||||
3. Deploy.
|
||||
4. New games created post-deploy land in `game.db` directly.
|
||||
|
||||
Pre-alpha there are only two users and a handful of test games; a one-time nuke is cheaper than a verified migration importer. Trade-off accepted by the user explicitly.
|
||||
|
||||
### Testing strategy (no migration)
|
||||
|
||||
Without migration, we don't need equivalence-vs-`PersistedHistory` testing. The correctness gate becomes:
|
||||
|
||||
1. **Adapt `PersistedHistoryTest`** to run against `SqliteHistory` instead. The 890-line existing test suite covers the trait surface exhaustively. Same assertions, new implementation under test.
|
||||
2. **Property-style end-to-end test**: create a synthetic game, run N batches of `withNewResults`, verify that `since(start)`, `stateAfter(N)`, `sinceDate(date)`, `recentResultsForRound`, `truncateTo`, etc., all produce expected values. Run with a deterministic random seed.
|
||||
3. **Manual smoke test in dev**: start a fresh game, play several turns, restart the server, verify saves persist and replay correctly.
|
||||
|
||||
## Cloud-storage integration
|
||||
|
||||
`Persister.save(key, bytes)` and `persister.retrieveAsStream(key)` already handle local-vs-S3 transparently. `SqliteClientTextStore` integrates by treating `text_store.db` as a single binary blob: upload after writes, download before reads.
|
||||
|
||||
`SqliteHistory` will do the same with `game.db`:
|
||||
- **On load:** if local `game.db` missing, try to download via `persister.retrieveAsStream("game.db")`. Fall back to migration from `.e0a` if both are missing.
|
||||
- **On save:** after a meaningful change (turn boundary? configurable cadence?), upload the current `game.db` file to S3. Need to decide the upload trigger — too frequent and we burn S3 PUTs; too rare and crash recovery is lossy.
|
||||
|
||||
**Open question:** the right upload cadence. Today, chunk saves trigger cloud upload at chunk boundaries (every 25 actions). The simplest match: keep that cadence, upload `game.db` once per chunk boundary. We could be smarter (only upload if WAL has been checkpointed; only upload deltas) but those are optimizations.
|
||||
|
||||
## What the trait-level cutover looks like
|
||||
|
||||
`GamesManager` currently has roughly:
|
||||
|
||||
```scala
|
||||
val history: FullGameHistory = PersistedHistory(gameId, persister).getOrElse(...)
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```scala
|
||||
val history: FullGameHistory = SqliteHistory.loaded(gameId, persister)
|
||||
```
|
||||
|
||||
Every other consumer of `FullGameHistory` is unchanged.
|
||||
|
||||
## Decisions locked
|
||||
|
||||
1. **Per-game `game.db`** (not shared across games).
|
||||
2. **`game.db` and `text_store.db` stay separate files** (not combined into one DB). Writer-lock contention is the deciding factor.
|
||||
3. **No migration** of existing saves. Nuke save directories at cutover. Pre-alpha trade-off accepted by the user.
|
||||
4. **No feature flag.** Cutover is unconditional. Failures are loud.
|
||||
5. **Cloud upload cadence**: match the existing 25-action chunk cadence. Upload `game.db` once per snapshot boundary.
|
||||
6. **`recentResultsForRound`**: return all results for the given round (no recent-window cap). The `round_id` predicate bounds the scan naturally.
|
||||
7. **WAL checkpointing**: rely on SQLite's auto-checkpoint. Add explicit `PRAGMA wal_checkpoint` only if WAL file growth becomes a problem.
|
||||
8. **Schema versioning**: seed `metadata` with `schema_version = 1`. Schema migration mechanism deferred until we need a v2.
|
||||
9. **`history.all`**: keep on the trait for now; admin call site moved to `since(i).headOption` (or a new `actionAt(i)` method) in a small follow-up PR. Not blocking.
|
||||
|
||||
## Phase plan (revised)
|
||||
|
||||
| Phase | Work | Estimate |
|
||||
|---|---|---|
|
||||
| 0 | This design doc | 2-3 days (in flight) |
|
||||
| 1 | `SqliteHistory` schema + write paths + tests adapted from `PersistedHistoryTest` | ~1 week |
|
||||
| 2 | Read paths + full test parity | ~1 week |
|
||||
| 3 | Shardok results integration | 3-5 days |
|
||||
| 4 | Cutover in `GamesManager` (nuke `${EAGLE_SAVE_DIR}` as part of deploy) | 2-3 days |
|
||||
| 5 | Idle-game eviction | 3-5 days |
|
||||
| 6 | Post-alpha cleanup (delete `PersistedHistory`, `PersistedActionResult`, `PartialGameUtils`, the chunk-file save code, `games.e0es` cache if no longer needed) | 1-2 days |
|
||||
|
||||
**Total: ~3-4 weeks** (down from 5-6 — migration and equivalence-testing dropped).
|
||||
|
||||
The follow-up to migrate `GameAdminServiceImpl.getActionDetail` off `history.all` is a small, independent PR that can land any time.
|
||||
@@ -32,7 +32,7 @@ When a new player starts their first game in tutorial mode, they experience:
|
||||
- 1x Heavy Cavalry (600 troops, 80 training/armament)
|
||||
- 1x Heavy Infantry (500 troops, 80 training/armament)
|
||||
- 1x Longbowmen (300 troops, 80 training/armament)
|
||||
- **Origin Province**: 32
|
||||
- **Origin Province**: 31
|
||||
|
||||
### Flee Trigger
|
||||
Attacker flees when ANY of these conditions are met:
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# Tutorial System Architecture
|
||||
|
||||
This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs:
|
||||
|
||||
- **`TUTORIAL_CONTENT.md`** — human-facing copy for tutorial steps and dialogues
|
||||
- **`TUTORIAL_BATTLE_SYSTEM.md`** — the scripted first-battle scenario (Tarn vs. John Ranil)
|
||||
|
||||
It also overlaps slightly with the in-tree `Assets/Tutorial/TUTORIAL_PLAN.md`, which is an older implementation plan.
|
||||
|
||||
All paths below are relative to `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/`.
|
||||
|
||||
---
|
||||
|
||||
## Current State (important)
|
||||
|
||||
There are **two parallel subsystems** that share the same trigger plumbing:
|
||||
|
||||
| Subsystem | Status | Lives in |
|
||||
|-----------|--------|----------|
|
||||
| Step-based tutorial UI (modals, overlays, hints) | **Dormant** | `Tutorial/TutorialManager.cs`, `Tutorial/UI/*`, `Tutorial/Content/*` |
|
||||
| Narrative dialogue system | **Live** | `Tutorial/Dialogue/*`, `Resources/Dialogues/*.json` |
|
||||
|
||||
`TutorialContentDefinitions.RegisterAll()` returns early at line 17 with the comment *"Old tutorial content suppressed — replaced by narrative dialogue system."* As a result:
|
||||
|
||||
- `OnboardingSequence` is never assigned, so `StartOnboarding()` no-ops at the "No onboarding sequence assigned" branch.
|
||||
- No contextual tutorial sequences are registered with the trigger registry.
|
||||
- The trigger registry still fires events normally, but nothing on the step-UI side is listening.
|
||||
- `DialogueManager` consumes those same events and matches them against scripts in `Resources/Dialogues/` (`tutorial_strategic.json`, `tutorial_battle.json`).
|
||||
|
||||
**In practice today, the entire active tutorial experience runs through the dialogue system.** The step-UI machinery is preserved for future use — content definitions still exist below the early return in `TutorialContentDefinitions.cs` for reference.
|
||||
|
||||
---
|
||||
|
||||
## Class Map
|
||||
|
||||
### Orchestration
|
||||
- **`Tutorial/TutorialManager.cs`** — Singleton. Owns `OnboardingSequence`, the active step queue, the trigger registry, and the dialogue manager handle. Initialized by `EagleGameController.SetUpGame()` and `ShardokGameController.SetUpGame()`.
|
||||
- **`Tutorial/TutorialState.cs`** — PlayerPrefs persistence. Tracks `OnboardingCompleted`, `OnboardingStepReached`, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference.
|
||||
- **`Tutorial/TutorialTargetRegistry.cs`** — Maps string IDs to `RectTransform`s for highlighting. Static targets are Inspector-assigned (`ProvinceInfoPanel`, `SupportField`, `CommitButton`, etc.); dynamic targets register at runtime (e.g., hero rows from `HeroesAndBattalionsPanelController`).
|
||||
|
||||
### Triggers
|
||||
- **`Tutorial/Triggers/TutorialTriggerRegistry.cs`** (~1100 lines) — Routes game events to both the step UI (when sequences are registered) and `DialogueManager`. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice.
|
||||
|
||||
### Content (step UI — dormant)
|
||||
- **`Tutorial/Content/TutorialStep.cs`** — Per-step record: `DisplayMode`, `CompletionType`, target path, copy, panel anchor, highlight options.
|
||||
- **`Tutorial/Content/TutorialSequence.cs`** — `ScriptableObject` holding ordered `TutorialStep`s plus `IsOnboarding` flag and lifecycle callbacks.
|
||||
- **`Tutorial/Content/TutorialContentDefinitions.cs`** — Static class that *would* register all sequences. Currently short-circuits before any registration.
|
||||
|
||||
### UI (step UI — dormant)
|
||||
- **`Tutorial/UI/TutorialUIManager.cs`** — Coordinates which presenter renders each step.
|
||||
- **`Tutorial/UI/TutorialCanvasBuilder.cs`** — Builds the Canvas at runtime (no prefab dependency).
|
||||
- **`Tutorial/UI/TutorialModalPanel.cs`** — Full-screen blocking modal.
|
||||
- **`Tutorial/UI/TutorialOverlayController.cs`** + **`TutorialOverlayBuilder.cs`** — Dimmer with highlighted cutout, gold border, pulsing animation, and adjacent tooltip text.
|
||||
- **`Tutorial/UI/TutorialHintIndicator.cs`** — Stub for pulsing-dot mode.
|
||||
|
||||
### Dialogue (live)
|
||||
- **`Tutorial/Dialogue/DialogueManager.cs`** — Loads all `Resources/Dialogues/*.json` scripts at startup, indexes by trigger ID, drives the panel.
|
||||
- **`Tutorial/Dialogue/DialogueScript.cs`** / **`DialogueStep.cs`** — JSON-shaped records for scripts and steps.
|
||||
- **`Tutorial/Dialogue/DialoguePanelController.cs`** — Renders the speaker headshot, body text, instruction line, optional highlight target, and Continue button.
|
||||
- **Scripts:** `Assets/Resources/Dialogues/tutorial_strategic.json`, `tutorial_battle.json`.
|
||||
|
||||
---
|
||||
|
||||
## Step Lifecycle (Step-UI System)
|
||||
|
||||
1. **Triggered** — `TutorialTriggerRegistry` raises an event (e.g., `game_started`, `battle_entered`).
|
||||
2. **Queued or shown** — If no active sequence, show immediately. If an active step is hidden (`DisplayMode.None`), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue.
|
||||
3. **Rendered** — `TutorialUIManager.ShowCurrentStep()` dispatches to the modal/overlay/hint presenter based on `DisplayMode`.
|
||||
4. **Awaiting completion** — One of:
|
||||
- `ButtonClick` — user dismisses
|
||||
- `GameEvent` — wait for a named event (e.g., `province_selected`)
|
||||
- `Timer` — auto-advance after delay
|
||||
- `UIInteraction` — wait for the highlighted target to be interacted with
|
||||
- `Condition` — custom predicate (rare)
|
||||
5. **Advance** — `AdvanceStep()` moves to the next step or completes the sequence; completion writes to `TutorialState`.
|
||||
|
||||
Hidden steps (`DisplayMode.None`) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event.
|
||||
|
||||
---
|
||||
|
||||
## Display Modes
|
||||
|
||||
| Mode | Renderer | Notes |
|
||||
|------|----------|-------|
|
||||
| **Modal** | `TutorialModalPanel` | Full blocking dialog with dim background |
|
||||
| **Overlay** | `TutorialOverlayController` | Dimmer with cutout + tooltip near a target |
|
||||
| **Tooltip** | `TutorialOverlayController` | Currently same renderer as overlay |
|
||||
| **Hint** | `TutorialHintIndicator` | Pulsing dot only (stub) |
|
||||
| **None** | (invisible) | Waits for a completion event |
|
||||
|
||||
Panels can be anchored via `TutorialPanelAnchor` (`Center` / `Left` / `Right` / `Top` / `Bottom`).
|
||||
|
||||
---
|
||||
|
||||
## Trigger Catalog
|
||||
|
||||
All triggers are raised via `TutorialTriggerRegistry`. Today these flow to `DialogueManager.TriggerDialogue()` and are matched against the dialogue JSON; if step-UI sequences are re-registered, they will also route there. File:line citations are for the registry unless noted; line numbers may drift.
|
||||
|
||||
### Bootstrap / first-session
|
||||
| Trigger | Fires from | When |
|
||||
|---------|-----------|------|
|
||||
| `game_started` | `EagleGameController.SetUpGame()` | First time entering a tutorial game |
|
||||
| `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears |
|
||||
| `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Battle!** |
|
||||
| `tutorial_battle_ended` | `CheckTutorialBattleEnded()` ~L594 | Tutorial battle removed from running models |
|
||||
| `tutorial_rebuild_support` | `CheckTutorialRebuildSupport()` ~L616 | Captured-heroes phase done |
|
||||
| `tutorial_taxes_collected` | `CheckTaxesCollected()` ~L680 | New Year action with positive tax delta |
|
||||
| `tutorial_in_town` | `CheckInTown()` ~L500 | Return command becomes available |
|
||||
|
||||
### Strategic-map contextual
|
||||
| Trigger | Site | When |
|
||||
|---------|------|------|
|
||||
| `province_selected` | `EagleGameController.ProvinceWasSelected()` | Province click |
|
||||
| `command_issued` | `EagleGameController.PostCommittedCommand()` | User commits |
|
||||
| `diplomacy_available` | `CheckStrategicCommandsAvailable()` ~L243 | Diplomacy command appears |
|
||||
| `weather_control_available` | `CheckStrategicCommandsAvailable()` ~L249 | Weather command appears |
|
||||
| `hero_recruitment_available` | `CheckHeroRecruitmentAvailable()` ~L266 | Free heroes detected |
|
||||
| `tutorial_ready_to_join` | `CheckReadyToJoinHero()` ~L479 | Free hero with `WouldJoin` |
|
||||
| `profession_<X>_encountered` | `CheckProfessionTutorial()` ~L726 | First time seeing each profession |
|
||||
|
||||
### Strategic guidance (state-derived, conditions checked each model update)
|
||||
| Trigger | Site | Condition |
|
||||
|---------|------|-----------|
|
||||
| `guidance_loyalty_danger` | `CheckLoyaltyDanger()` ~L339 | November + hero loyalty < 70 |
|
||||
| `guidance_neighbor_danger` | `CheckNeighborDanger()` ~L383 | Hostile faction adjacent |
|
||||
| `guidance_recruit_heroes` | `CheckRecruitHeroesGuidance()` ~L420 | Province with support ≥40 has free heroes |
|
||||
| `guidance_expand` | `CheckExpandGuidance()` ~L449 | One stable province, hasn't expanded |
|
||||
| `guidance_sworn_kinship` | `CheckSwornKinshipGuidance()` ~L549 | Good candidate or 4+ provinces |
|
||||
| `tutorial_loyalty_warning` | `CheckTutorialLoyaltyWarning()` ~L634 | November + hero loyalty < 70 |
|
||||
| `tutorial_support_deadline` | `CheckTutorialSupportDeadline()` ~L654 | December + province support < 40 |
|
||||
|
||||
### Strategic action-result triggers (fired from `OnStrategicActionResult()`)
|
||||
| Trigger | Site | When |
|
||||
|---------|------|------|
|
||||
| `hero_stat_gained` | ~L281 | `HeroStatGained` action result |
|
||||
| `hero_profession_gained` | ~L284 | `ProfessionGained` action result |
|
||||
| `tutorial_faction_appears` | ~L287 | `TutorialFactionAppears` action — the Fracture Covenant landing |
|
||||
| `tutorial_hero_faction_appears` | ~L290 | `TutorialHeroFactionAppears` action (no dialogue script today) |
|
||||
| `tutorial_hero_departed` | ~L299 | First `HeroesDeparted` action against another player (King's hero abandons service) |
|
||||
| `tutorial_hero_departed_again` | ~L302 | Second `HeroesDeparted` in a *different* month than the first |
|
||||
|
||||
### Tactical (battle) — abilities and spells
|
||||
Combat triggers are *paced*: `_combatTutorialPending` ensures only one fires per action, with priority `archery > duel > melee > charge > fire`.
|
||||
|
||||
| Trigger | Site | When |
|
||||
|---------|------|------|
|
||||
| `shardok_placement_started` | `OnBattleEntered()` ~L796 | Battle in setup phase |
|
||||
| `shardok_battle_started` | `OnBattleEntered()` ~L793 | Battle begins |
|
||||
| `shardok_battle_running` | `OnTacticalCommandsAvailable()` ~L948 | Player's first turn |
|
||||
| `archery_available` | `OnTacticalCommandsAvailable()` ~L990 | Archery available |
|
||||
| `melee_available` | `OnTacticalCommandsAvailable()` ~L991 | Melee available |
|
||||
| `duel_available` | `OnTacticalCommandsAvailable()` ~L1005 | Duel available (non-Tarn target) |
|
||||
| `ability_charge_available` | `OnTacticalCommandsAvailable()` ~L1001 | Charge for Old Marek |
|
||||
| `start_fire_available` | `OnTacticalCommandsAvailable()` ~L1021 | Start Fire on enemy |
|
||||
| `hide_available` | `OnTacticalCommandsAvailable()` ~L985 | Hedrick can hide |
|
||||
| `thunderstorm` | `OnTacticalCommandsAvailable()` ~L1056 | Weather is thunderstorm |
|
||||
| `spell_lightning_available` | `OnTacticalCommandsAvailable()` ~L960 | Lightning available |
|
||||
| `spell_meteor_available` | `OnTacticalCommandsAvailable()` ~L963 | Meteor available |
|
||||
| `spell_holywave_available` | `OnTacticalCommandsAvailable()` ~L965 | Holy Wave available |
|
||||
| `spell_raisedead_available` | `OnTacticalCommandsAvailable()` ~L969 | Raise Dead available |
|
||||
| `spell_lightning_cast` / `spell_meteor_cast` / `spell_holywave_cast` / `spell_raisedead_cast` | `CheckTacticalActionType()` ~L830-838 | Spell action observed |
|
||||
| `ability_charge_used` | `CheckTacticalActionType()` ~L842 | Charge attack executed |
|
||||
| `terrain_fire_encountered` | `CheckTacticalActionType()` ~L864 | Fire damage / spread |
|
||||
| `terrain_water_encountered` | `CheckTacticalActionType()` ~L867 | Water crossing |
|
||||
| `engineer_near_enemy` | `CheckEngineerNearEnemy()` ~L1105 | John Ranil within 3 hexes of enemy |
|
||||
| `tutorial_reinforcement_engineer` / `tutorial_reinforcement_paladin` | `CheckTacticalActionType()` ~L857 | Reinforcement arrival |
|
||||
| `friendly_hero_captured` | `CheckHeroRemovals()` ~L919 | Friendly hero unit removed |
|
||||
| `enemy_hero_captured` | `CheckHeroRemovals()` ~L928 | Enemy hero unit removed |
|
||||
| `battle_action` | `ShardokGameController.OnBattleAction()` | Any action result |
|
||||
| `turn_ended` | `ShardokGameController.OnTurnEnded()` ~L1137 | Battle turn ends |
|
||||
| `shardok_battle_reset` | `ShardokGameController.cs:627` (fired *directly* to `DialogueManager`, bypassing the registry) | Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire |
|
||||
|
||||
### One-shot semantics
|
||||
Most contextual triggers are guarded by booleans on the registry (e.g., a `_diplomacyShown` flag). These are session-local — they don't persist via `TutorialState`, but the registry has `ResetBattleTriggerFlags()` for replays. The strategic completion list *does* persist across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Highlight Targets
|
||||
|
||||
Steps reference UI by string ID through `TutorialTargetRegistry`:
|
||||
|
||||
1. Static targets are wired in the Inspector on the registry.
|
||||
2. Dynamic targets register at runtime (`RegisterTarget(id, rectTransform)`).
|
||||
3. Lookup falls back to `GameObject.Find()` if not registered.
|
||||
|
||||
Step options:
|
||||
- `TargetGameObjectPath` — primary highlight
|
||||
- `AdditionalHighlightTargets[]` — multiple at once
|
||||
- `HighlightBoundsFromChildren` — use children's bounding box
|
||||
- `HighlightPulsing` — strobe animation
|
||||
|
||||
Dialogue scripts use the same registry via `highlightTarget` (and `persistHighlight: true` to keep the highlight after the dialogue closes).
|
||||
|
||||
---
|
||||
|
||||
## Sequences vs. Contextual Dispatch
|
||||
|
||||
- **Onboarding sequence** (`IsOnboarding = true`) — Single linear sequence, started by `StartOnboarding()`. Resumes from `OnboardingStepReached`. Counts only visible steps for progress.
|
||||
- **Contextual** (`IsOnboarding = false`) — Single- or multi-step, dispatched on demand by `TriggerContextualTutorial()`.
|
||||
- Dispatch rules in `TriggerContextualTutorial()`:
|
||||
1. No active sequence → show immediately.
|
||||
2. Active step is hidden (`None`) → interrupt.
|
||||
3. Both are command tutorials → interrupt.
|
||||
4. Otherwise → queue.
|
||||
|
||||
---
|
||||
|
||||
## Dialogue System (Currently Live)
|
||||
|
||||
`DialogueManager` is a parallel system, not a step-UI subclass. It loads every `TextAsset` in `Resources/Dialogues/` at startup, parses them as `DialogueScript` objects, and indexes by `trigger`.
|
||||
|
||||
Script shape (see `tutorial_strategic.json`):
|
||||
```json
|
||||
{
|
||||
"scripts": [{
|
||||
"id": "tutorial_opening",
|
||||
"trigger": "game_started",
|
||||
"panelPosition": null,
|
||||
"steps": [{
|
||||
"speakerName": "Old Marek the Learned",
|
||||
"speakerImagePath": "fixed/old_marek_the_learned.png",
|
||||
"dialogueText": "...",
|
||||
"instructionText": "Click Battle! to enter tactical combat.",
|
||||
"highlightTarget": "GoToBattleButton",
|
||||
"persistHighlight": true,
|
||||
"highlightProvince": "Onmaa",
|
||||
"completionEvent": null
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
When a registry trigger fires, `OnGameEvent()` calls `DialogueManager.TriggerDialogue(triggerId)`. If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a `completionEvent` (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes.
|
||||
|
||||
Dialogue panel position defaults sensibly per scene (`top` during combat) and can be overridden per script.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
`TutorialState` (PlayerPrefs JSON):
|
||||
- `OnboardingCompleted` (bool)
|
||||
- `OnboardingStepReached` (int)
|
||||
- `CompletedTutorials` (List → HashSet at load)
|
||||
- `DismissedHints` (List → HashSet at load)
|
||||
- `AllTutorialsDisabled` (bool)
|
||||
|
||||
Reset paths:
|
||||
- `TutorialState.Reset()` clears everything.
|
||||
- Entering a tutorial game (`GameType.Tutorial`) resets so it can be replayed.
|
||||
- Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`.
|
||||
|
||||
`DialogueManager` tracks completed scripts in memory only — they reset whenever `TutorialState` does.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap
|
||||
|
||||
1. `TutorialManager` is in the scene as a singleton; `Awake` loads state and creates the trigger registry. `RegisterAll()` is called here but currently no-ops.
|
||||
2. `ConnectionHandler` sets `IsTutorialGame` and `IsMultiplayerGame` when the game is created/joined.
|
||||
3. `EagleGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(this, null)`. If `IsTutorialGame && !OnboardingCompleted`, it would call `StartOnboarding()` — currently a no-op because `OnboardingSequence` is null.
|
||||
4. The first model update raises `game_started`. `DialogueManager` matches `tutorial_opening` from `tutorial_strategic.json` and the dialogue runs.
|
||||
5. `ShardokGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(null, this)` when battle starts; raises `battle_entered` and battle-only triggers from there.
|
||||
|
||||
---
|
||||
|
||||
## Expected Tutorial Flow (today)
|
||||
|
||||
Driven by `tutorial_strategic.json` + `tutorial_battle.json`. Old Marek the Learned narrates almost everything.
|
||||
|
||||
### Narrative arc
|
||||
|
||||
You play **Sadar Rakon**, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the **Reclamation**. He's been pushed back to **Onmaa** for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — **The Fracture Covenant**, led by a mysterious figure called *The Eagle* — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion."
|
||||
|
||||
### Strategic-map flow (`tutorial_strategic.json`)
|
||||
|
||||
| # | Trigger | What happens |
|
||||
|---|---------|--------------|
|
||||
| 1 | `game_started` | **Opening monologue** (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the **Battle!** button (`persistHighlight`) |
|
||||
| 2 | `tutorial_battle_ended` | **Aftermath**: Tarn has *vanished*; you've captured his lieutenants. Recruit them or hold them |
|
||||
| 3 | `tutorial_rebuild_support` | **Rebuild Onmaa** (3 steps): Marek frames it; **John Ranil** introduces *Improve* (engineer bonus); **Elena Fyar** introduces *Give Alms* (paladin bonus). Goal: 40 support by January |
|
||||
| 4 | `tutorial_loyalty_warning` | November-ish, low-loyalty hero may leave at year end → use *Give Gold* / *Feast* |
|
||||
| 5 | `tutorial_support_deadline` | December nag if support < 40: have Elena give alms now |
|
||||
| 6 | `guidance_expand` (script `tutorial_expansion`) | Once support is stable: keep developing, taxes coming in January |
|
||||
| 7 | `tutorial_taxes_collected` | January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 1–2 behind |
|
||||
| 8 | `tutorial_ready_to_join` | A free hero in `{provinceName}` is ready to recruit → Travel + Recruit |
|
||||
| 9 | `tutorial_in_town` | When you Travel: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; *Return* ends the turn |
|
||||
| 10 | `tutorial_faction_appears` | **The twist**: Fracture Covenant lands at Ingia and Soria. *The Eagle* commands them. Tarn may be with them |
|
||||
| 11 | `tutorial_hero_departed` | First King's hero abandons service — "something deeper is at work" |
|
||||
| 12 | `tutorial_hero_departed_again` | Second one in a different month — "something is wrong, I can feel it" (foreshadowing) |
|
||||
|
||||
### Tactical battle flow (`tutorial_battle.json`)
|
||||
|
||||
| # | Trigger | What happens |
|
||||
|---|---------|--------------|
|
||||
| 1 | `shardok_placement_started` | **Placement** (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit |
|
||||
| 2 | `shardok_battle_running` | **Turn 1 strategy** (2 steps): victory conditions for both sides, advice to hold castles and End Turn |
|
||||
| 3 | `archery_available` | First archery opportunity → purple outlines, longbows vs. armor |
|
||||
| 4 | `melee_available` | First melee opportunity → right-click adjacent enemy |
|
||||
| 5 | `ability_charge_available` | Charge mechanics (Marek hams it up: "old scholar without a horse") |
|
||||
| 6 | `start_fire_available` | Start Fire on enemy hex, fire spread mechanics |
|
||||
| 7 | `thunderstorm` | Weather: archery disabled, fires extinguished |
|
||||
| 8 | `tutorial_reinforcement_paladin` | **Elena Fyar arrives** mid-battle (2-step exchange) → Paladin profession intro (Holy Wave) |
|
||||
| 9 | `tutorial_reinforcement_engineer` | **John Ranil arrives** (2-step exchange) → Engineer profession intro |
|
||||
| 10 | `engineer_near_enemy` | Once Ranil is close to enemy: Fortify + Reduce (siege bombardment) |
|
||||
| 11 | `duel_available` | Champion duel mechanics (Marek warns *not* to duel Tarn himself) |
|
||||
| 12 | `hide_available` | Hedrick the Hedge-merchant: forest/swamp Hide + ambush |
|
||||
| 13 | `enemy_hero_captured` / `friendly_hero_captured` | Capture mechanics, with `{heroName}` substitution |
|
||||
| 14 | `shardok_battle_reset` | If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing |
|
||||
|
||||
Most remaining "Tutorial / first-session onboarding" items in `SMALL_EAGLE_TODO.md` (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to *adding new dialogue scripts* against existing trigger IDs, or — if the step-UI is revived — to populating `TutorialContentDefinitions.RegisterAll()` and removing the early return.
|
||||
|
||||
---
|
||||
|
||||
## Notable Behaviors and Gotchas
|
||||
|
||||
- **`IsTutorialGame` gates everything.** Outside tutorial games, both subsystems short-circuit early.
|
||||
- **Combat tutorials are deliberately paced.** Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them.
|
||||
- **One-shot flags are session-local.** A trigger that "already fired" will not re-fire even if `TutorialState` is reset, until the registry is recreated.
|
||||
- **`TutorialTargetRegistry` lookups silently fall back to `GameObject.Find`.** Typos in target IDs will render with no highlight rather than throwing.
|
||||
- **`TutorialContentDefinitions.RegisterAll()` returning early is load-bearing.** If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger.
|
||||
- **`Tutorial/TUTORIAL_PLAN.md`** in the Unity project is an older implementation plan and partially overlaps this doc.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Unity Scene Separation Plan
|
||||
|
||||
## Current State
|
||||
|
||||
The Unity client currently has both a large legacy scene and an unfinished split-scene setup.
|
||||
|
||||
- `Assets/Gameplay.unity` is still the production-sized monolithic scene. It contains connection/lobby UI, Eagle strategic UI, Shardok tactical UI, shared UI, camera/event-system objects, and miscellaneous persistent controllers.
|
||||
- `Assets/Scenes/Main.unity`, `Connection.unity`, `Eagle.unity`, `Shardok.unity`, and `Shared.unity` already exist, but they are small shell scenes.
|
||||
- `ProjectSettings/EditorBuildSettings.asset` includes both `Assets/Gameplay.unity` and the split scenes.
|
||||
- `Assets/UI/Scripts/BootstrapManager.cs` is explicitly transitional and currently defaults to loading `Gameplay` additively through `useOriginalGameplayScene = true`.
|
||||
- Additional migration notes exist under `Assets/SCENE_SEPARATION_PLAN.md`, `Assets/Scenes/README.md`, and `Assets/NEXT_STEPS.md`, but there is no repo-level plan in `docs/`.
|
||||
|
||||
This means the project is partway through a scene-separation migration, but runtime still depends on the monolithic scene.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Finish the split-scene architecture. Keep one small bootstrap scene and load mode-specific UI scenes additively.
|
||||
|
||||
The target architecture should be:
|
||||
|
||||
- `Main`: startup/bootstrap only. Owns scene loading and truly persistent app services.
|
||||
- `Shared`: shared UI and app-wide interaction infrastructure, such as the event system, error panels, confirmation modals, connection status overlays, settings, and update notifications.
|
||||
- `Connection`: login, account selection, lobby, custom battle creation, and game-selection UI.
|
||||
- `Eagle`: strategic map, strategic command panels, province/hero/battalion UI, notifications, and Eagle-specific effects.
|
||||
- `Shardok`: tactical battle UI, hex grid, battle command UI, unit rows, tactical effects, and battle-only camera/input helpers.
|
||||
|
||||
Do not treat large prefabs as inherently bad. A large prefab is acceptable when it represents a coherent, reusable feature root. The problematic pattern is a large scene that owns unrelated application states and long-lived object references.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reduce merge conflicts and accidental Unity serialization churn.
|
||||
- Make login/lobby, Eagle, and Shardok changes independently editable.
|
||||
- Make scene lifecycle explicit: load only the mode currently needed.
|
||||
- Avoid cross-scene inspector references that are hard to reason about.
|
||||
- Preserve a safe fallback until each migrated section is validated.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not refactor gameplay logic and scene structure in the same change.
|
||||
- Do not delete `Gameplay.unity` until all migrated scenes are verified in Editor, local builds, and CI.
|
||||
- Do not move large vendor asset packs as part of this work.
|
||||
- Do not split every small widget into its own scene. Prefer prefabs for reusable UI components and scenes for application modes.
|
||||
|
||||
## Target Runtime Flow
|
||||
|
||||
```text
|
||||
Main
|
||||
Load Shared
|
||||
Load Connection
|
||||
|
||||
Connection -> Eagle
|
||||
Unload Connection
|
||||
Ensure Shared remains loaded
|
||||
Load Eagle
|
||||
|
||||
Eagle -> Shardok
|
||||
Keep Eagle loaded unless the battle UI must exclusively own the viewport
|
||||
Load Shardok additively
|
||||
|
||||
Shardok -> Eagle
|
||||
Unload Shardok
|
||||
Keep Eagle and Shared loaded
|
||||
|
||||
Eagle -> Connection
|
||||
Unload Shardok if loaded
|
||||
Unload Eagle
|
||||
Load Connection
|
||||
```
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
Use these rules during migration to avoid ambiguous object lifetimes.
|
||||
|
||||
- `Main` owns `GameSceneManager`, app bootstrap, and any service object that must survive across login, lobby, Eagle, and Shardok.
|
||||
- `Shared` owns exactly one active `EventSystem` and shared modal/overlay UI.
|
||||
- `Connection` owns `ConnectionHandler` and connection/lobby panel instances.
|
||||
- `Eagle` owns `EagleGameController` and strategic-mode scene roots.
|
||||
- `Shardok` owns `ShardokGameController` and tactical-mode scene roots.
|
||||
- Cross-mode communication should happen through model/controller APIs, not direct serialized references to objects in another loadable scene.
|
||||
- If a scene needs an object from another scene, prefer resolving it during scene initialization and failing loudly when required dependencies are missing.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Make Bootstrap Explicit
|
||||
|
||||
- Make `Main.unity` the intended startup scene.
|
||||
- Update `BootstrapManager` so the split-scene path is the default in development builds.
|
||||
- Keep a temporary fallback flag for loading `Gameplay.unity`.
|
||||
- Ensure build settings order is intentional: `Main` first, then loadable scenes. Leave `Gameplay` present only while fallback is still required.
|
||||
- Add logging that states which startup path was chosen.
|
||||
|
||||
Validation:
|
||||
|
||||
- Enter Play Mode from `Main.unity`.
|
||||
- Confirm `Shared` and `Connection` load without duplicate `EventSystem` objects.
|
||||
- Confirm fallback still loads `Gameplay.unity` if explicitly enabled.
|
||||
|
||||
### Phase 2: Migrate Shared Infrastructure
|
||||
|
||||
- Move shared error UI, update UI, settings UI, connection status overlays, and global modal roots into `Shared.unity`.
|
||||
- Keep only one event system active when additive scenes are loaded.
|
||||
- Remove duplicated shared UI from `Gameplay.unity` only after migrated UI is validated.
|
||||
|
||||
Validation:
|
||||
|
||||
- Trigger an error modal from Connection, Eagle, and Shardok flows.
|
||||
- Open/close settings from each mode that supports it.
|
||||
- Verify no duplicate event-system warnings.
|
||||
|
||||
### Phase 3: Migrate Connection and Lobby
|
||||
|
||||
- Move `ConnectionCanvas`, login controls, account selector, lobby, custom battle UI, and game-selection rows into `Connection.unity`.
|
||||
- Make `ConnectionHandler` initialize after the scene is loaded rather than assuming objects exist in `Gameplay.unity`.
|
||||
- Replace show/hide transitions that activate large inactive subtrees with scene transitions where appropriate.
|
||||
|
||||
Validation:
|
||||
|
||||
- Start unauthenticated, authenticate, list available games, create/drop a custom battle, and enter a game.
|
||||
- Return from an active game to the lobby.
|
||||
- Test stored account flows.
|
||||
|
||||
### Phase 4: Migrate Eagle Strategic UI
|
||||
|
||||
- Move strategic map, command panel roots, hero/battalion/province UI, Eagle notifications, and strategic effects into `Eagle.unity`.
|
||||
- Keep reusable row/panel widgets as prefabs under `Assets/Eagle/`.
|
||||
- Remove direct dependencies on connection-scene objects.
|
||||
|
||||
Validation:
|
||||
|
||||
- Load a game from the lobby.
|
||||
- Select provinces, heroes, battalions, command buttons, and notifications.
|
||||
- Issue at least one representative command from each major command-panel family.
|
||||
- Confirm generated/dynamic text listeners clean up when leaving the scene.
|
||||
|
||||
### Phase 5: Migrate Shardok Tactical UI
|
||||
|
||||
- Move Shardok canvas, hex grid, battle panels, tactical rows, battle effects, and battle-only input/camera roots into `Shardok.unity`.
|
||||
- Decide whether Eagle remains visible/loaded under Shardok. Prefer keeping Eagle loaded if returning from battle must preserve strategic UI state.
|
||||
- Ensure Shardok scene unload disposes generated text listeners, subscriptions, object pools, and pending animation state.
|
||||
|
||||
Validation:
|
||||
|
||||
- Enter battle from Eagle.
|
||||
- Select units, issue movement/attack/fire/extinguish/end-turn commands.
|
||||
- Resolve a battle and return to Eagle.
|
||||
- Start a second battle in the same session to catch stale singleton/listener state.
|
||||
|
||||
### Phase 6: Remove Legacy Scene Dependency
|
||||
|
||||
- Flip the default so `Gameplay.unity` is not loaded during normal startup.
|
||||
- Remove `Gameplay.unity` from build settings once no supported flow needs it.
|
||||
- Keep `Gameplay.unity` in the repo temporarily as a reference snapshot if useful, then delete it in a separate cleanup PR.
|
||||
- Remove transitional helpers whose only purpose was loading or testing the legacy scene.
|
||||
|
||||
Validation:
|
||||
|
||||
- Local Editor smoke test through login, Eagle, Shardok, and return flows.
|
||||
- Unity edit-mode test suite if available.
|
||||
- CI Unity build.
|
||||
- TestFlight build before deleting the legacy scene.
|
||||
|
||||
## Review Checklist For Each Migration PR
|
||||
|
||||
- The PR moves one coherent subsystem, not multiple unrelated UI areas.
|
||||
- Scene files remain small enough to review.
|
||||
- Prefab changes are limited to the migrated subsystem.
|
||||
- No required Inspector field is hidden behind a defensive null check.
|
||||
- No duplicate `EventSystem`, camera, global canvas, or singleton is active.
|
||||
- Leaving and re-entering the migrated scene works in one app session.
|
||||
- The old `Gameplay.unity` object is removed only after the new scene object is verified.
|
||||
|
||||
## Risks
|
||||
|
||||
- Serialized cross-scene references will break when objects move. Prefer runtime lookup/initialization through explicit scene controllers.
|
||||
- Singletons may survive scene unload and hold stale references. Audit `OnDestroy`, `Dispose`, and listener cleanup for every moved controller.
|
||||
- Additive scenes can accidentally create duplicate input/event/camera objects. Keep these centralized in `Shared` or `Main`.
|
||||
- Loading Shardok additively over Eagle can expose hidden assumptions about active canvases, cameras, and selected input targets.
|
||||
- Unity scene YAML changes are easy to corrupt manually. Prefer moving objects in the Unity Editor and reviewing diffs carefully.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
- Whether Shardok should overlay Eagle additively or unload most Eagle UI while battle is active.
|
||||
- Whether the primary persistent game connection object belongs in `Main` or should be recreated per selected game.
|
||||
- Whether `Shared` should always load at startup or only after successful connection.
|
||||
- How long to keep `Gameplay.unity` after the split scene flow is fully validated.
|
||||
|
||||
## Suggested First PR
|
||||
|
||||
The first implementation PR should not move Eagle or Shardok. It should:
|
||||
|
||||
1. Make `Main.unity` the startup scene in build settings.
|
||||
2. Make the split-scene bootstrap path explicit and logged.
|
||||
3. Load `Shared` plus `Connection` by default.
|
||||
4. Keep a fallback flag for loading `Gameplay.unity`.
|
||||
5. Validate login/lobby startup in Editor and CI.
|
||||
|
||||
That gives a controlled base for subsequent subsystem migrations.
|
||||
@@ -0,0 +1,173 @@
|
||||
# URP Migration Plan
|
||||
|
||||
## Why
|
||||
|
||||
Unity is removing Built-In Render Pipeline (BiRP) support after Unity 6.7. We're currently on Unity 6.4. Each Unity minor version ships roughly quarterly, giving us a few release cycles before BiRP is dropped. This document lays out the migration plan.
|
||||
|
||||
## Strategy: Long-Lived Feature Branch
|
||||
|
||||
All URP conversion work happens on a **long-lived `urp-migration` branch**. Main stays fully functional on BiRP throughout the migration.
|
||||
|
||||
- **Main branch**: Continues using BiRP. All gameplay, AI, and server development proceeds normally.
|
||||
- **URP branch**: Accumulates rendering pipeline changes across all phases.
|
||||
- **Regular rebasing**: Periodically rebase/merge `main` into the URP branch to stay current. Shader and material changes rarely conflict with gameplay code, so merge conflicts should be manageable.
|
||||
- **Merge to main**: Only when everything renders correctly and visual QA passes.
|
||||
|
||||
This avoids the "everything is pink" problem of switching the pipeline on main before shaders are converted.
|
||||
|
||||
## Current State
|
||||
|
||||
| Category | Count | Notes |
|
||||
|---|---|---|
|
||||
| Shaders | 47 | 7 custom Eagle, 1 hex mesh, 20 Polytope, 13 TextMesh Pro, 6 other third-party |
|
||||
| Materials | 325 | Mix of Standard shader, custom, and third-party |
|
||||
| ShaderGraph files | 4 | All TextMesh Pro (URP + HDRP variants already exist) |
|
||||
| Scenes with baked lighting | 6 | Gameplay, Connection, Eagle, Shardok, Shared, Map Editor |
|
||||
| Post-processing | 1 profile | PPP_Orc.asset (Post Processing Stack v2, FXAA/TAA, AO) |
|
||||
| C# rendering scripts | 16 | Material/shader property manipulation only |
|
||||
| Rendering path | Forward | Matches URP default |
|
||||
|
||||
### Positive Findings
|
||||
|
||||
- **No OnRenderImage, Graphics.Blit, CommandBuffer, or GL.\* usage** in C# code
|
||||
- Forward rendering already in use (matches URP default)
|
||||
- TextMesh Pro already has URP ShaderGraph variants in the project
|
||||
- No custom render passes or ScriptableRenderFeatures
|
||||
- Shader property manipulation (SetTexture, SetFloat, SetColor) is URP-compatible
|
||||
|
||||
### Known Risk Areas
|
||||
|
||||
- **GrabPass shaders** (HeatShimmerShader, PT_Water_Shader) have no direct URP equivalent
|
||||
- **ProvinceMapShader** is complex (province ID lookup, border rendering, ocean animation, faction highlighting, UI clipping)
|
||||
- **Surface shaders** (`#pragma surface surf Standard`) must be rewritten as HLSL or ShaderGraph
|
||||
- **Polytope Studio shaders** (20 shaders) have no vendor-provided URP variants
|
||||
- **Post Processing Stack v2** must be replaced with URP's integrated Volume system
|
||||
- **Tessellation** (PT_Water_Shader) is not natively supported in URP
|
||||
|
||||
## Shader Inventory
|
||||
|
||||
### Custom Eagle Shaders (7) -- HIGH PRIORITY
|
||||
|
||||
| Shader | Complexity | Key Issues |
|
||||
|---|---|---|
|
||||
| ProvinceMapShader | High | Province ID texture lookup, border rendering, ocean animation, faction highlighting, `UnityUI.cginc` dependency, stencil/clipping |
|
||||
| ProvinceWeatherMapShader | Medium | Weather overlay rendering |
|
||||
| ProvinceWeatherShader | Medium | Province weather effects |
|
||||
| ProvinceParticleShader | Low | Custom particle rendering |
|
||||
| HeatShimmerShader | High | **GrabPass** for screen distortion, province masking |
|
||||
| ClipRectParticleUnlit | Low | UI-clipped particle shader |
|
||||
| maskShader | Low | UI masking |
|
||||
|
||||
### Hex Mesh Shader (1) -- MODERATE PRIORITY
|
||||
|
||||
Uses `#pragma surface surf Standard` with GPU instancing. Straightforward conversion to URP Lit or ShaderGraph.
|
||||
|
||||
### Third-Party Shaders (39)
|
||||
|
||||
| Source | Count | Complexity | Notes |
|
||||
|---|---|---|---|
|
||||
| Polytope Studio | 20 | Moderate-High | PBR, Toon, vegetation (custom lighting), water (**GrabPass + tessellation**). No vendor URP pack available. |
|
||||
| TextMesh Pro | 13 | Low | URP ShaderGraph variants already exist in project |
|
||||
| RRFreelance | 3 | Low | Standard surface shaders, direct conversion |
|
||||
| Clown.fat | 2 | Moderate | Custom ToonRamp lighting model |
|
||||
| GUI Pro Kit | 1 | Low | Hidden particle shader |
|
||||
|
||||
### GrabPass Shaders (Require Special Handling)
|
||||
|
||||
GrabPass does not exist in URP. These must be reimplemented using `ScriptableRenderPass` + `Renderer Features` or Blit-based alternatives:
|
||||
|
||||
1. **HeatShimmerShader** -- Screen distortion effect (currently has a shimmer-disabled TODO, may be deprioritized)
|
||||
2. **PT_Water_Shader** -- Water refraction/transparency (PT_Water_Shader_WebGl exists without GrabPass as a reference)
|
||||
|
||||
## Phased Approach
|
||||
|
||||
### Phase 1: Pipeline Setup + Auto-Conversion (Days 1-3)
|
||||
|
||||
- Install URP package
|
||||
- Create URP Pipeline Asset and Renderer Asset
|
||||
- Configure basic pipeline settings (forward rendering, shadow settings)
|
||||
- Run Unity's **Render Pipeline Converter** (Edit > Rendering > Render Pipeline Converter)
|
||||
- Auto-converts Standard shader materials and some built-in shaders
|
||||
- Handles a significant portion of the 325 materials
|
||||
- Will NOT touch custom shaders
|
||||
- Create a test scene to validate basic rendering
|
||||
- Migrate viewport clipping system (`RendererViewportClipper`, `ViewportClipper`, `PopupClipper`) to use URP-compatible global shader properties
|
||||
|
||||
### Phase 2: Custom Eagle Shaders (Weeks 1-3)
|
||||
|
||||
This is the critical path. Without these, the game is unplayable.
|
||||
|
||||
**Week 1-2: ProvinceMapShader**
|
||||
- Convert from BiRP Cg/HLSL to URP HLSL
|
||||
- Replace `UnityCG.cginc` includes with `Core.hlsl` / `Common.hlsl`
|
||||
- Replace `UnityUI.cginc` with custom URP-compatible UI clipping
|
||||
- Preserve province ID texture lookup, border rendering, ocean animation, faction highlighting
|
||||
- Validate stencil operations and render queue ordering
|
||||
|
||||
**Week 2-3: Remaining Eagle shaders**
|
||||
- ProvinceWeatherMapShader + ProvinceWeatherShader
|
||||
- ProvinceParticleShader + ClipRectParticleUnlit
|
||||
- maskShader
|
||||
- Hex Mesh Shader (convert surface shader to URP Lit)
|
||||
- HeatShimmerShader (reimplement without GrabPass, or defer if shimmer remains disabled)
|
||||
|
||||
### Phase 3: Third-Party Shaders (Weeks 4-6)
|
||||
|
||||
**Polytope Studio (20 shaders)**
|
||||
- Convert PBR shaders (Armors, NPC, Weapons, Props, Rock) from surface shaders to URP Lit / ShaderGraph
|
||||
- Convert Toon shaders to custom URP shader or ShaderGraph with custom lighting
|
||||
- Convert vegetation shaders (custom `StandardCustom` lighting) to ShaderGraph
|
||||
- PT_Water_Shader: Reimplement without GrabPass and tessellation (use PT_Water_Shader_WebGl as reference for non-GrabPass approach)
|
||||
|
||||
**Other third-party**
|
||||
- Clown.fat ToonRamp shaders: Convert custom lighting model to ShaderGraph
|
||||
- RRFreelance shaders: Direct Standard-to-URP-Lit conversion
|
||||
- GUI Pro Kit particle shader: Convert to URP particle shader
|
||||
|
||||
### Phase 4: Materials, Lighting & Post-Processing (Weeks 7-8)
|
||||
|
||||
**Materials**
|
||||
- Batch-update any remaining materials not handled by the auto-converter
|
||||
- Verify all 325 materials render correctly
|
||||
- Fix any visual differences from lighting model changes
|
||||
|
||||
**Lighting**
|
||||
- Rebake lightmaps for all 6 scenes (Gameplay, Connection, Eagle, Shardok, Shared, Map Editor)
|
||||
- Configure URP shadow cascade settings to match current quality levels
|
||||
- Verify HDR rendering
|
||||
|
||||
**Post-Processing**
|
||||
- Remove Post Processing Stack v2 dependency (`com.unity.postprocessing: 3.5.1`)
|
||||
- Replace with URP integrated Volume system
|
||||
- Recreate PPP_Orc effects (FXAA/TAA, Ambient Occlusion) using URP Volume overrides
|
||||
|
||||
### Phase 5: Testing & Validation (Weeks 9-11)
|
||||
|
||||
- Visual QA across all 6 scenes
|
||||
- Verify beast/character rendering (vertex colors, animations)
|
||||
- Verify weather effects (blizzard, drought, flood)
|
||||
- Verify particle systems (fire & explosion effects, UI particles)
|
||||
- Verify UI rendering (GUI Pro Kit, Modern UI Pack, TextMesh Pro)
|
||||
- Performance profiling (URP has different performance characteristics)
|
||||
- Test on target platforms
|
||||
- Bug fixes and visual polish
|
||||
|
||||
## Effort Estimates
|
||||
|
||||
| Phase | Best Case | Realistic | Worst Case |
|
||||
|---|---|---|---|
|
||||
| 1. Pipeline Setup | 1-2 days | 2-3 days | 3-5 days |
|
||||
| 2. Custom Eagle Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
|
||||
| 3. Third-Party Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
|
||||
| 4. Materials & Lighting | 3-5 days | 1-1.5 weeks | 2 weeks |
|
||||
| 5. Testing & Validation | 1 week | 1.5-2 weeks | 2-3 weeks |
|
||||
| **Total** | **6-8 weeks** | **8-12 weeks** | **12-16 weeks** |
|
||||
|
||||
The biggest variable is Polytope Studio shader conversion. If vendor URP packs become available, Phase 3 shrinks significantly.
|
||||
|
||||
## Key Reminders
|
||||
|
||||
- **Don't leave GrabPass rewrites and ProvinceMapShader for the end** -- they're the riskiest pieces and should be tackled early.
|
||||
- The migration window (~1 year) is comfortable. Spreading the work across this window is fine, but front-load the hard shader work.
|
||||
- C# code changes should be minimal -- the codebase avoids direct rendering API usage.
|
||||
- `UnityUI.cginc` has no direct URP equivalent. Custom implementation will be needed for UI clipping in shaders.
|
||||
+119
-51
@@ -4,17 +4,36 @@ This document describes which quests the AI attempts to complete proactively to
|
||||
|
||||
## Overview
|
||||
|
||||
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
|
||||
The AI attempts to complete most quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`.
|
||||
|
||||
Important scope limits:
|
||||
|
||||
- The selector only considers quests from unaffiliated heroes in provinces owned by the acting faction.
|
||||
- It further filters to provinces ruled by that faction's leader.
|
||||
- It only emits a command when the corresponding command is currently available.
|
||||
- Handler order is fixed. The first handler that produces a valid command wins.
|
||||
|
||||
## Quests the AI Actively Completes
|
||||
|
||||
This section lists quests with direct `QuestCommandChooser` handlers in `FulfillQuestsCommandSelector`.
|
||||
|
||||
### Diplomacy Quests
|
||||
|
||||
| Quest | Handler | Conditions |
|
||||
|-------|---------|------------|
|
||||
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
|
||||
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
|
||||
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
|
||||
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
|
||||
| `BetrayAllyQuest` | `BetrayAllyQuestCommandChooser` | Break alliance with target faction. Only if factions don't share a border. Sends weakest non-leader hero. |
|
||||
|
||||
### Province Development Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
|
||||
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
|
||||
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
|
||||
| `TotalDevelopmentQuest` | `TotalDevelopmentQuestCommandChooser` | Improve the lowest stat in the target province |
|
||||
|
||||
### Resource Giving Quests
|
||||
|
||||
@@ -24,72 +43,99 @@ The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is
|
||||
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
|
||||
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
|
||||
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
|
||||
| `SpendOnFeastsInProvinceQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts in the quest holder's province |
|
||||
| `SpendOnFeastsAcrossRealmQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on the most expensive currently available feast |
|
||||
| `SendSuppliesQuest` | `SendSuppliesQuestCommandChooser` | Send food to the target province |
|
||||
|
||||
### Province Development Quests
|
||||
### Prisoner Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
|
||||
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
|
||||
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
|
||||
| `ReleasePrisonerQuest` | `ReleasePrisonerQuestCommandChooser` | Release a specific prisoner |
|
||||
| `ExilePrisonerQuest` | `ExilePrisonerQuestCommandChooser` | Exile a specific prisoner |
|
||||
| `ExecutePrisonerQuest` | `ExecutePrisonerQuestCommandChooser` | Execute a specific prisoner |
|
||||
| `ReturnPrisonerQuest` | `ReturnPrisonerQuestCommandChooser` | Return a prisoner to their faction |
|
||||
| `ReleaseAllPrisonersQuest` | `ReleaseAllPrisonersQuestCommandChooser` | Release all prisoners |
|
||||
|
||||
### Province Order Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `DevelopProvincesQuest` | `DevelopProvincesQuestCommandChooser` | Switches provinces to Develop order. Prefers provinces without hostile neighbors. |
|
||||
| `MobilizeProvincesQuest` | `MobilizeProvincesQuestCommandChooser` | Switches provinces to Mobilize order. Prefers provinces with hostile neighbors. Skipped if a DevelopProvincesQuest also exists (conflict avoidance). |
|
||||
|
||||
### Weather/Epidemic Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `StartEpidemicQuest` | `StartEpidemicQuestCommandChooser` | Start an epidemic in a province. Skipped if the target province belongs to the acting faction. |
|
||||
| `StartBlizzardQuest` | `ControlWeatherQuestCommandChooser` | Start a blizzard via ControlWeather command. Skipped if the target province belongs to the acting faction. |
|
||||
| `StartDroughtQuest` | `ControlWeatherQuestCommandChooser` | Start a drought via ControlWeather command. Skipped if the target province belongs to the acting faction. |
|
||||
|
||||
### Military Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `GrandArmyQuest` | `GrandArmyQuestCommandChooser` | Uses OrganizeTroops to top off existing battalions and hire Light Infantry. Only executes if resources are sufficient to fully meet the troop target. |
|
||||
| `BattalionDiversityQuest` | `BattalionDiversityQuestCommandChooser` | Hires one battalion of a missing type to reach 3+ distinct types. Only attempts if the province already has 2+ existing types, has sufficient development (`meetsRequirements`), and gold/food surplus. |
|
||||
| `UpgradeBattalionQuest` | `UpgradeBattalionQuestCommandChooser` | Walks a priority tree per turn: arm-completes (with optional travel and/or food sale setup) → train-completes → march in a pre-qualified neighbor battalion → organize/top-off of the target type → march in an unqualified neighbor battalion → develop Economy/Agriculture → train progress → develop Infrastructure → arm progress. Only toggles Travel when it directly enables an arm-completes finisher. |
|
||||
|
||||
### Reconnaissance Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `ReconSpecificProvincesQuest` | `ReconSpecificProvincesQuestCommandChooser` | Recon specific target provinces. Prefers not-yet-reconned targets. |
|
||||
| `ReconProvincesQuest` | `ReconProvincesQuestCommandChooser` | Issues a Recon command to reconnoiter a province |
|
||||
|
||||
### Beast Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `FightBeastsAloneQuest` | `FightBeastsAloneQuestCommandChooser` | Send an expendable non-leader hero to fight beasts without a battalion. Hero must have power at most `MaxExpendableHeroPowerRatio` (default 0.75) times the quest-giving hero's power. Picks the weakest eligible hero. |
|
||||
|
||||
### Special Event Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `ApprehendOutlawQuest` | `ApprehendOutlawQuestCommandChooser` | Apprehend a specific outlaw hero when present in the province |
|
||||
|
||||
### Other Quests
|
||||
|
||||
| Quest | Handler | Conditions |
|
||||
|-------|---------|------------|
|
||||
| `RestProvinceQuest` | `RestProvinceQuestCommandChooser` | Use the Rest command in a specific province |
|
||||
| `SwearBrotherhoodWithHeroQuest` | `SwearBrotherhoodQuestCommandChooser` | Swear brotherhood with a specific hero |
|
||||
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
|
||||
|
||||
## Quests the AI Does Not Attempt to Complete
|
||||
## Quests the AI Handles Indirectly
|
||||
|
||||
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
|
||||
These are not in `FulfillQuestsCommandSelector`, but other AI command-selection code can still bias toward completing them.
|
||||
|
||||
### Combat/Military Quests
|
||||
- `DefeatFactionQuest` - Defeat a specific faction
|
||||
- `GrandArmyQuest` - Accumulate a large number of troops
|
||||
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
|
||||
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
|
||||
- `WinBattlesQuest` - Win a number of battles
|
||||
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. |
|
||||
|
||||
### Expansion Quests
|
||||
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
|
||||
- `SpecificExpansionQuest` - Conquer a specific province
|
||||
- `BorderSecurityQuest` - Have troops in a border province
|
||||
## Quests the AI Does Not Yet Attempt to Complete
|
||||
|
||||
### Prisoner Quests
|
||||
- `ExecutePrisonerQuest` - Execute a specific prisoner
|
||||
- `ExilePrisonerQuest` - Exile a specific prisoner
|
||||
- `ReleasePrisonerQuest` - Release a specific prisoner
|
||||
- `ReturnPrisonerQuest` - Return a prisoner to their faction
|
||||
- `ReleaseAllPrisonersQuest` - Release all prisoners
|
||||
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
|
||||
|
||||
### Province Order Quests
|
||||
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
|
||||
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
|
||||
- `RestProvinceQuest` - Use the Rest command in a specific province
|
||||
### Planned for Implementation
|
||||
|
||||
### Reconnaissance Quests
|
||||
- `ReconProvincesQuest` - Reconnoiter a number of provinces
|
||||
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
|
||||
None currently planned.
|
||||
|
||||
### Economic Quests
|
||||
- `TotalDevelopmentQuest` - Achieve total development level in a province
|
||||
- `WealthQuest` - Accumulate gold and food
|
||||
- `SpendOnFeastsQuest` - Spend gold on feasts
|
||||
- `SendSuppliesQuest` - Send food to a specific province
|
||||
- `RepairDevastationQuest` - Repair devastation
|
||||
### Not Planned
|
||||
|
||||
### Special Event Quests
|
||||
- `SuppressRiotByForceQuest` - Suppress a riot by force
|
||||
- `FightBeastsAloneQuest` - Fight beasts alone
|
||||
- `StartBlizzardQuest` - Start a blizzard in a province
|
||||
- `StartEpidemicQuest` - Start an epidemic in a province
|
||||
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
|
||||
These quests are too situational, passive, or risky to actively pursue. They may be completed naturally through gameplay.
|
||||
|
||||
### Miscellaneous
|
||||
- `BattalionDiversityQuest` - Have diverse battalion types
|
||||
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
|
||||
- `BetrayAllyQuest` - Betray an allied faction
|
||||
- `BorderSecurityQuest` - Have troops in a border province. Involves taking provinces; better left to organic expansion.
|
||||
- `DefeatFactionQuest` - Defeat a specific faction. Too risky to pursue proactively.
|
||||
- `SpecificExpansionQuest` - Conquer a specific province. Too risky.
|
||||
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces. Too risky.
|
||||
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered. Can't reliably engineer.
|
||||
- `WinBattlesQuest` - Win a number of battles. Passive.
|
||||
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader. Complex multi-step.
|
||||
- `WealthQuest` - Accumulate gold and food. Happens passively.
|
||||
- `RepairDevastationQuest` - Repair devastation. Happens passively.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
@@ -102,12 +148,34 @@ Each chooser extends either:
|
||||
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
|
||||
|
||||
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
|
||||
1. Alliance
|
||||
2. TruceWithFaction
|
||||
3. Improve (Agriculture/Economy/Infrastructure)
|
||||
1. TruceWithFaction
|
||||
2. Improve (Agriculture/Economy/Infrastructure)
|
||||
3. TotalDevelopment
|
||||
4. AlmsToProvince
|
||||
5. GiveToHeroesInProvince
|
||||
6. AlmsAcrossRealm
|
||||
7. GiveToHeroesAcrossRealm
|
||||
8. TruceCount
|
||||
9. DismissSpecificVassal
|
||||
10. ReleasePrisoner
|
||||
11. ExilePrisoner
|
||||
12. ExecutePrisoner
|
||||
13. ReturnPrisoner
|
||||
14. ReleaseAllPrisoners
|
||||
15. ApprehendOutlaw
|
||||
16. SpendOnFeastsInProvince / SpendOnFeastsAcrossRealm
|
||||
17. RestProvince
|
||||
18. DevelopProvinces
|
||||
19. MobilizeProvinces
|
||||
20. SendSupplies
|
||||
21. StartEpidemic
|
||||
22. ControlWeather (StartBlizzard/StartDrought)
|
||||
23. GrandArmy
|
||||
24. FightBeastsAlone
|
||||
25. BattalionDiversity
|
||||
26. UpgradeBattalion
|
||||
27. ReconSpecificProvinces
|
||||
28. ReconProvinces
|
||||
29. SwearBrotherhood
|
||||
30. Alliance
|
||||
31. BetrayAlly
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,10 @@
|
||||
|
||||
set -e
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
# Run gazelle
|
||||
bazel run //:gazelle 2>/dev/null
|
||||
bazel run //:gazelle
|
||||
|
||||
# Check if any BUILD files were modified
|
||||
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook wrapper for scalafmt.
|
||||
# Runs the repository-pinned scalafmt CLI through Bazel.
|
||||
|
||||
set -e
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
files=()
|
||||
for file in "$@"; do
|
||||
case "$file" in
|
||||
/*) files+=("$file") ;;
|
||||
*) files+=("$repo_root/$file") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
bazel run //tools:scalafmt -- --config "$repo_root/.scalafmt.conf" -i "${files[@]}"
|
||||
+27
-14
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keeps Bazel mactools builds in sync with the installed Xcode version.
|
||||
# Keeps Bazel macOS builds in sync with the installed Xcode version.
|
||||
#
|
||||
# Only needed on runners that do mactools builds (Mac/Sparkle). Non-Mac builds
|
||||
# don't run this script; their apple_cc_autoconf stays cached because none of
|
||||
# its environ vars change, so they're unaffected by Xcode version changes.
|
||||
# Needed on macOS runners and developer machines because Bazel's generated
|
||||
# local_config_xcode/local_config_apple_cc repositories bake in the discovered
|
||||
# Xcode version. If Xcode is upgraded in place, the DEVELOPER_DIR path stays the
|
||||
# same, so Bazel can otherwise keep using stale Xcode metadata.
|
||||
#
|
||||
# 1. Writes .bazelrc.xcode with mactools-scoped flags:
|
||||
# 1. Writes .bazelrc.xcode with macOS-scoped flags:
|
||||
# - action_env for remote cache key invalidation
|
||||
# - DEVELOPER_DIR pointing to the active Xcode
|
||||
#
|
||||
@@ -17,17 +18,24 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
XCODE_BUILD_VERSION=$(xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}')
|
||||
|
||||
if [ -z "$XCODE_BUILD_VERSION" ]; then
|
||||
echo "Warning: Could not detect Xcode build version, skipping"
|
||||
exit 0
|
||||
DEVELOPER_DIR=""
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||
else
|
||||
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null)
|
||||
XCODE_BUILD_VERSION=$(DEVELOPER_DIR="$DEVELOPER_DIR" xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}' || true)
|
||||
|
||||
if [ -z "$XCODE_BUILD_VERSION" ]; then
|
||||
echo "ERROR: Could not detect full Xcode build version."
|
||||
echo "mactools/Sparkle builds require full Xcode, not just Command Line Tools."
|
||||
echo "Install Xcode at /Applications/Xcode.app or select it with xcode-select."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BAZELRC_XCODE=".bazelrc.xcode"
|
||||
EXPECTED_LINE="common:mactools --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
|
||||
EXPECTED_LINE="common:macos --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
|
||||
|
||||
# Check if the file already has the right version (first line is the version marker)
|
||||
if [ -f "$BAZELRC_XCODE" ]; then
|
||||
@@ -49,6 +57,11 @@ bazel clean --expunge 2>/dev/null || true
|
||||
|
||||
cat > "$BAZELRC_XCODE" << EOF
|
||||
${EXPECTED_LINE}
|
||||
common:mactools --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
|
||||
common:macos --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
|
||||
EOF
|
||||
echo "Updated ${BAZELRC_XCODE} — next mactools build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "DEVELOPER_DIR=${DEVELOPER_DIR}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
echo "Updated ${BAZELRC_XCODE} — next macOS Bazel build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
|
||||
|
||||
@@ -66,6 +66,14 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "shardok_latency_trace",
|
||||
srcs = ["ShardokLatencyTrace.cpp"],
|
||||
hdrs = ["ShardokLatencyTrace.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "repeated_proto_utils",
|
||||
srcs = [],
|
||||
|
||||
@@ -88,6 +88,11 @@ StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() {
|
||||
engine.seed(static_cast<std::mt19937_64::result_type>(std::time(nullptr)));
|
||||
}
|
||||
|
||||
StdLibraryGenerator::StdLibraryGenerator(const std::mt19937_64::result_type seed)
|
||||
: RandomGenerator() {
|
||||
engine.seed(seed);
|
||||
}
|
||||
|
||||
auto StdLibraryGenerator::IntBetween(const int min, const int max) -> int {
|
||||
std::uniform_int_distribution<int> unifInt(min, max - 1);
|
||||
return unifInt(engine);
|
||||
|
||||
@@ -51,6 +51,7 @@ private:
|
||||
|
||||
public:
|
||||
StdLibraryGenerator();
|
||||
explicit StdLibraryGenerator(std::mt19937_64::result_type seed);
|
||||
|
||||
auto IntBetween(int min, int max) -> int override; // inclusive, exclusive (min<=return<max)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#include "ShardokLatencyTrace.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
namespace net::eagle0::common {
|
||||
namespace {
|
||||
|
||||
auto ReadEnvEnabled() -> bool {
|
||||
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_TRACE");
|
||||
if (value == nullptr) { return false; }
|
||||
const std::string text(value);
|
||||
return text == "1" || text == "true" || text == "TRUE" || text == "yes" || text == "YES";
|
||||
}
|
||||
|
||||
auto TraceEnabled() -> bool {
|
||||
static const bool enabled = ReadEnvEnabled();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
auto ReadGameFilter() -> std::string {
|
||||
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_GAME_ID");
|
||||
return value == nullptr ? "" : std::string(value);
|
||||
}
|
||||
|
||||
auto GameFilter() -> const std::string& {
|
||||
static const std::string filter = ReadGameFilter();
|
||||
return filter;
|
||||
}
|
||||
|
||||
auto JsonEscape(std::string_view value) -> std::string {
|
||||
std::ostringstream out;
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '"': out << "\\\""; break;
|
||||
case '\\': out << "\\\\"; break;
|
||||
case '\n': out << "\\n"; break;
|
||||
case '\r': out << "\\r"; break;
|
||||
case '\t': out << "\\t"; break;
|
||||
default: out << c; break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
auto LogMutex() -> std::mutex& {
|
||||
static auto mutex = new std::mutex();
|
||||
return *mutex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool {
|
||||
if (!TraceEnabled()) { return false; }
|
||||
const std::string& filter = GameFilter();
|
||||
return filter.empty() || filter == gameId;
|
||||
}
|
||||
|
||||
void EmitShardokLatencyTrace(
|
||||
std::string_view event,
|
||||
std::string_view gameId,
|
||||
const ShardokLatencyFields& fields,
|
||||
std::chrono::steady_clock::duration duration) {
|
||||
if (!ShardokLatencyTraceEnabledForGame(gameId)) { return; }
|
||||
|
||||
const auto durationMs =
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(duration).count() / 1000.0;
|
||||
|
||||
std::ostringstream out;
|
||||
out << "{\"event\":\"shardok_latency\","
|
||||
<< "\"component\":\"shardok\","
|
||||
<< "\"span\":\"" << JsonEscape(event) << "\","
|
||||
<< "\"game_id\":\"" << JsonEscape(gameId) << "\","
|
||||
<< "\"duration_ms\":" << durationMs;
|
||||
for (const auto& [key, value] : fields) {
|
||||
out << ",\"" << JsonEscape(key) << "\":\"" << JsonEscape(value) << "\"";
|
||||
}
|
||||
out << "}";
|
||||
|
||||
std::scoped_lock lock(LogMutex());
|
||||
std::cerr << out.str() << std::endl;
|
||||
}
|
||||
|
||||
ScopedShardokLatencyTrace::ScopedShardokLatencyTrace(
|
||||
std::string event,
|
||||
std::string gameId,
|
||||
ShardokLatencyFields fields)
|
||||
: event_(std::move(event)),
|
||||
gameId_(std::move(gameId)),
|
||||
fields_(std::move(fields)) {
|
||||
if (ShardokLatencyTraceEnabledForGame(gameId_)) { start_ = std::chrono::steady_clock::now(); }
|
||||
}
|
||||
|
||||
ScopedShardokLatencyTrace::~ScopedShardokLatencyTrace() {
|
||||
if (!start_.has_value()) { return; }
|
||||
EmitShardokLatencyTrace(event_, gameId_, fields_, std::chrono::steady_clock::now() - *start_);
|
||||
}
|
||||
|
||||
} // namespace net::eagle0::common
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace net::eagle0::common {
|
||||
|
||||
using ShardokLatencyFields = std::vector<std::pair<std::string, std::string>>;
|
||||
|
||||
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool;
|
||||
|
||||
void EmitShardokLatencyTrace(
|
||||
std::string_view event,
|
||||
std::string_view gameId,
|
||||
const ShardokLatencyFields& fields,
|
||||
std::chrono::steady_clock::duration duration);
|
||||
|
||||
class ScopedShardokLatencyTrace {
|
||||
public:
|
||||
ScopedShardokLatencyTrace(
|
||||
std::string event,
|
||||
std::string gameId,
|
||||
ShardokLatencyFields fields = {});
|
||||
~ScopedShardokLatencyTrace();
|
||||
|
||||
ScopedShardokLatencyTrace(const ScopedShardokLatencyTrace&) = delete;
|
||||
auto operator=(const ScopedShardokLatencyTrace&) -> ScopedShardokLatencyTrace& = delete;
|
||||
ScopedShardokLatencyTrace(ScopedShardokLatencyTrace&&) = delete;
|
||||
auto operator=(ScopedShardokLatencyTrace&&) -> ScopedShardokLatencyTrace& = delete;
|
||||
|
||||
private:
|
||||
std::string event_;
|
||||
std::string gameId_;
|
||||
ShardokLatencyFields fields_;
|
||||
std::optional<std::chrono::steady_clock::time_point> start_;
|
||||
};
|
||||
|
||||
} // namespace net::eagle0::common
|
||||
@@ -21,7 +21,14 @@ namespace shardok::mcts {
|
||||
|
||||
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
|
||||
: playerId_(playerId),
|
||||
config_(config) {}
|
||||
config_(config),
|
||||
randomEngine_(config.randomSeed == 0 ? std::random_device{}() : config.randomSeed) {}
|
||||
|
||||
void AbstractMCTSAI::SetConfig(const MCTSConfig& newConfig) {
|
||||
config_ = newConfig;
|
||||
std::lock_guard lock(randomEngineMutex_);
|
||||
randomEngine_.seed(newConfig.randomSeed == 0 ? std::random_device{}() : newConfig.randomSeed);
|
||||
}
|
||||
|
||||
auto AbstractMCTSAI::Search(
|
||||
const MCTSGameEngine& engine,
|
||||
@@ -350,22 +357,19 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
// fair UCB comparison with non-chance actions like END_TURN
|
||||
{
|
||||
double expectedImmediate = 0.0;
|
||||
double totalProbability = 0.0;
|
||||
for (size_t i = 0; i < node->children.size(); i++) {
|
||||
for (size_t i = 0; i < node->outcomeProbabilities.size(); i++) {
|
||||
const double prob = node->outcomeProbabilities[i];
|
||||
const double childImmediate = node->children[i]->immediateScore;
|
||||
const double childImmediate = i < node->children.size()
|
||||
? node->children[i]->immediateScore
|
||||
: node->unvisitedOutcomeScore;
|
||||
expectedImmediate += prob * childImmediate;
|
||||
totalProbability += prob;
|
||||
}
|
||||
// Normalize by total probability of expanded outcomes
|
||||
if (totalProbability > 0.0) {
|
||||
node->immediateScore = expectedImmediate / totalProbability;
|
||||
// CRITICAL: Always update lookaheadScore to the expected value.
|
||||
// Without this, chance nodes keep their initial lookaheadScore from the parent
|
||||
// state (before the action), while regular actions use the child state (after).
|
||||
// This gives chance nodes an unfair initial UCB advantage.
|
||||
node->lookaheadScore = node->immediateScore;
|
||||
}
|
||||
node->immediateScore = expectedImmediate;
|
||||
// CRITICAL: Always update lookaheadScore to the expected value.
|
||||
// Without this, chance nodes keep their initial lookaheadScore from the parent
|
||||
// state (before the action), while regular actions use the child state (after).
|
||||
// This gives chance nodes an unfair initial UCB advantage.
|
||||
node->lookaheadScore = node->immediateScore;
|
||||
}
|
||||
|
||||
return node->children.back().get();
|
||||
@@ -396,8 +400,14 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
|
||||
const auto& action = nodeActions[actionIndex];
|
||||
|
||||
const double actionWeight =
|
||||
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
|
||||
double totalActionWeight = 0.0;
|
||||
for (const double weight : actionWeights) { totalActionWeight += std::max(0.0, weight); }
|
||||
|
||||
const double rawActionWeight =
|
||||
actionIndex < actionWeights.size() ? std::max(0.0, actionWeights[actionIndex]) : 1.0;
|
||||
const double actionWeight = totalActionWeight > 0.0
|
||||
? rawActionWeight / totalActionWeight
|
||||
: 1.0 / static_cast<double>(nodeActions.size());
|
||||
|
||||
// Check if this action requires a chance node
|
||||
if (action->requiresChanceNode()) {
|
||||
@@ -417,15 +427,16 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
// Get outcome information from engine
|
||||
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
|
||||
|
||||
// Set up outcome metadata (2 outcomes for binary actions)
|
||||
// Set up outcome metadata.
|
||||
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
|
||||
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
|
||||
chanceNode->totalActions = 2; // Binary: success and failure
|
||||
chanceNode->totalActions = chanceNode->outcomeProbabilities.size();
|
||||
|
||||
// Chance node immediate score will be computed as expected value during backpropagation
|
||||
// For now, initialize to parent's score as a reasonable default
|
||||
chanceNode->immediateScore = engine.evaluateState(*node->gameState, playerId_);
|
||||
chanceNode->lookaheadScore = chanceNode->immediateScore;
|
||||
chanceNode->unvisitedOutcomeScore = chanceNode->immediateScore;
|
||||
|
||||
// Set parent and add to children
|
||||
chanceNode->parent = node;
|
||||
@@ -602,29 +613,20 @@ auto AbstractMCTSAI::MCTSBackpropagation(
|
||||
// Chance nodes: compute expected value (weighted average of outcomes)
|
||||
// lookaheadScore = sum(probability[i] * childValue[i])
|
||||
double expectedValue = 0.0;
|
||||
double totalProbability = 0.0;
|
||||
int visitedChildCount = 0;
|
||||
|
||||
for (size_t i = 0; i < node->children.size(); i++) {
|
||||
const auto& child = node->children[i];
|
||||
if (child->visitCount == 0) continue; // Unvisited outcomes don't contribute
|
||||
bool hasVisitedChild = false;
|
||||
|
||||
for (size_t i = 0; i < node->outcomeProbabilities.size(); i++) {
|
||||
const double probability = node->outcomeProbabilities[i];
|
||||
const double childValue = child->lookaheadScore;
|
||||
double childValue = node->unvisitedOutcomeScore;
|
||||
if (i < node->children.size() && node->children[i]->visitCount > 0) {
|
||||
childValue = node->children[i]->lookaheadScore;
|
||||
hasVisitedChild = true;
|
||||
}
|
||||
expectedValue += probability * childValue;
|
||||
totalProbability += probability;
|
||||
visitedChildCount++;
|
||||
}
|
||||
|
||||
// Use expected value if we have visited outcomes, else use average
|
||||
if (visitedChildCount > 0) {
|
||||
// CRITICAL: Normalize by total probability to get correct expected value
|
||||
// when not all outcomes have been visited yet
|
||||
if (totalProbability > 0.0 && totalProbability < 1.0) {
|
||||
// Normalize to account for unvisited outcomes
|
||||
// This gives the correct expected value among visited outcomes
|
||||
expectedValue /= totalProbability;
|
||||
}
|
||||
if (hasVisitedChild) {
|
||||
node->lookaheadScore = expectedValue;
|
||||
} else {
|
||||
// No outcomes visited yet, fall back to average
|
||||
@@ -705,23 +707,21 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
"MCTS tree building or game state");
|
||||
}
|
||||
|
||||
thread_local std::mt19937 gen(std::random_device{}());
|
||||
|
||||
switch (config_.simulationPolicy) {
|
||||
case MCTSSimulationPolicy::RANDOM: {
|
||||
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
|
||||
return dis(gen);
|
||||
return SampleDistribution(dis);
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::FILTERED_RANDOM: {
|
||||
if (const auto filteredIndices = engine.filterActions(actions, state);
|
||||
!filteredIndices.empty()) {
|
||||
std::uniform_int_distribution<size_t> dis(0, filteredIndices.size() - 1);
|
||||
return filteredIndices[dis(gen)];
|
||||
return filteredIndices[SampleDistribution(dis)];
|
||||
}
|
||||
// Fall back to random
|
||||
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
|
||||
return dis(gen);
|
||||
return SampleDistribution(dis);
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
|
||||
@@ -790,7 +790,7 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
|
||||
// Select based on weights
|
||||
std::discrete_distribution<> dis(weights.begin(), weights.end());
|
||||
return scores[dis(gen)].first;
|
||||
return scores[SampleDistribution(dis)].first;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
|
||||
@@ -820,13 +820,13 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
|
||||
// Select based on heuristic weights using discrete_distribution
|
||||
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
|
||||
return validIndices[dis(gen)];
|
||||
return validIndices[SampleDistribution(dis)];
|
||||
}
|
||||
}
|
||||
|
||||
// Default to random
|
||||
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
|
||||
return dis(gen);
|
||||
return SampleDistribution(dis);
|
||||
}
|
||||
|
||||
auto AbstractMCTSAI::FindNodeAtDepthWithHash(
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
@@ -41,7 +43,7 @@ public:
|
||||
|
||||
// Configuration
|
||||
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config_; }
|
||||
void SetConfig(const MCTSConfig& newConfig) { config_ = newConfig; }
|
||||
void SetConfig(const MCTSConfig& newConfig);
|
||||
|
||||
[[nodiscard]] auto FindNodeAtDepthWithHash(
|
||||
const MCTSNode* root,
|
||||
@@ -51,6 +53,8 @@ public:
|
||||
private:
|
||||
MCTSPlayerId playerId_;
|
||||
MCTSConfig config_;
|
||||
mutable std::mt19937 randomEngine_;
|
||||
mutable std::mutex randomEngineMutex_;
|
||||
|
||||
// Transposition table: maps state hash -> minimum depth at which state was reached
|
||||
// Used to detect and penalize longer paths to the same game state
|
||||
@@ -85,6 +89,13 @@ private:
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
bool isMaximizing) const -> size_t;
|
||||
|
||||
template<typename Distribution>
|
||||
[[nodiscard]] auto SampleDistribution(Distribution& distribution) const ->
|
||||
typename Distribution::result_type {
|
||||
std::lock_guard lock(randomEngineMutex_);
|
||||
return distribution(randomEngine_);
|
||||
}
|
||||
|
||||
// Logging
|
||||
static auto LogSearchResults(
|
||||
const MCTSNode* rootNode,
|
||||
@@ -103,4 +114,4 @@ private:
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
|
||||
@@ -6,6 +6,7 @@ cc_library(
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
|
||||
@@ -54,6 +54,7 @@ struct MCTSNode {
|
||||
// Chance node specific fields (only used when nodeType == CHANCE)
|
||||
std::vector<double> outcomeProbabilities; // Probability of each outcome
|
||||
std::vector<double> outcomeRolls; // Representative roll for each outcome
|
||||
double unvisitedOutcomeScore = 0.0; // Default value for outcomes not expanded yet
|
||||
|
||||
// Game context
|
||||
MCTSPlayerId playerId;
|
||||
@@ -274,4 +275,4 @@ struct MCTSNode {
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
|
||||
@@ -51,10 +51,11 @@ struct MCTSConfig {
|
||||
// When evaluating a leaf at playerFlips < maxSimulationFlips,
|
||||
// simulate forward to this phase for fair comparison
|
||||
// (default 0 = evaluate leaves as-is, backward compatible)
|
||||
uint32_t randomSeed = 0; // 0 = seed nondeterministically
|
||||
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_TYPES_HPP
|
||||
#endif // EAGLE0_MCTS_TYPES_HPP
|
||||
|
||||
@@ -54,6 +54,12 @@ static auto IsDeterministic(const CommandType type) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
static auto RandomnessSampleForRepeat(const int repeatIteration, const int maxRepeatCount)
|
||||
-> double {
|
||||
if (maxRepeatCount <= 1) { return 0.5; }
|
||||
return static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1);
|
||||
}
|
||||
|
||||
// Helper function to sort commands by score
|
||||
static auto CommandSorter(
|
||||
const AICommandEvaluator::IndexAndScore& l,
|
||||
@@ -85,15 +91,15 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
const ScoreValue currentUtility,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult> {
|
||||
// Check transposition table before expensive computation
|
||||
auto cachedScore =
|
||||
g_transpositionTable.probe(innerEngine->GetCurrentGameState(), remainingLookahead, pid);
|
||||
|
||||
if (cachedScore.has_value()) {
|
||||
// Return cached result immediately
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(*cachedScore);
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = *cachedScore, .completed = true});
|
||||
return p.get_future();
|
||||
}
|
||||
const auto nextUtility = currentUtility;
|
||||
@@ -105,8 +111,8 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
// table
|
||||
g_transpositionTable.store(innerEngine->GetCurrentGameState(), 1, pid, nextUtility);
|
||||
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(nextUtility);
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = nextUtility, .completed = true});
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
@@ -131,16 +137,18 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
innerEngine,
|
||||
pid,
|
||||
nextUtility,
|
||||
remainingLookahead]() mutable -> ScoreValue {
|
||||
const auto [index, type, lookaheadScore, immediateScore] =
|
||||
bestCommandFuture.get();
|
||||
remainingLookahead]() mutable -> EvaluationResult {
|
||||
const auto bestCommand = bestCommandFuture.get();
|
||||
if (!bestCommand.completed) {
|
||||
return EvaluationResult{.score = nextUtility, .completed = false};
|
||||
}
|
||||
|
||||
ScoreValue resultScore;
|
||||
if (auto& nextCommand =
|
||||
innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
|
||||
if (auto& nextCommand = innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(
|
||||
bestCommand.index);
|
||||
nextCommand->GetCommandType() !=
|
||||
net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
resultScore = immediateScore;
|
||||
resultScore = bestCommand.immediateScore;
|
||||
} else {
|
||||
resultScore = nextUtility;
|
||||
}
|
||||
@@ -152,7 +160,7 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
pid,
|
||||
resultScore);
|
||||
|
||||
return resultScore;
|
||||
return EvaluationResult{.score = resultScore, .completed = true};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,8 +168,8 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
g_transpositionTable
|
||||
.store(innerEngine->GetCurrentGameState(), remainingLookahead, pid, nextUtility);
|
||||
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(nextUtility);
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = nextUtility, .completed = true});
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
@@ -180,10 +188,10 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
|
||||
// Check if we've exceeded the deadline
|
||||
if (std::chrono::steady_clock::now() > deadline) {
|
||||
// Return with a default score and an empty future that resolves immediately
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(0.0); // Default timeout score
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = 0.0, .completed = false});
|
||||
returnValue.immediateScore = 0.0;
|
||||
returnValue.completed = false;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
return returnValue;
|
||||
}
|
||||
@@ -198,11 +206,12 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
allCastleCoords);
|
||||
|
||||
returnValue.immediateScore = innerUtility;
|
||||
returnValue.completed = true;
|
||||
|
||||
if (remainingLookahead <= 0) {
|
||||
std::promise<ScoreValue> p;
|
||||
std::promise<EvaluationResult> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
p.set_value(innerUtility);
|
||||
p.set_value(EvaluationResult{.score = innerUtility, .completed = true});
|
||||
} else {
|
||||
auto lookaheadLambda = [this,
|
||||
pid,
|
||||
@@ -213,7 +222,7 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
attackerStrategy,
|
||||
innerUtility,
|
||||
&allCastleCoords,
|
||||
deadline]() -> ScoreValue {
|
||||
deadline]() -> EvaluationResult {
|
||||
auto lookaheadFuture = PerformLookahead(
|
||||
pid,
|
||||
isDefender,
|
||||
@@ -231,7 +240,7 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
auto launchPolicy = remainingLookahead == 1 ? std::launch::async : std::launch::deferred;
|
||||
returnValue.lookaheadScore = std::async(launchPolicy, lookaheadLambda);
|
||||
#else
|
||||
std::promise<ScoreValue> p;
|
||||
std::promise<EvaluationResult> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
auto lambdaResult = lookaheadLambda();
|
||||
p.set_value(lambdaResult);
|
||||
@@ -316,7 +325,8 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
size_t index;
|
||||
CommandType type;
|
||||
ScoreValue immediateScore;
|
||||
std::vector<std::future<ScoreValue>> lookaheadFutures;
|
||||
bool immediateCompleted = true;
|
||||
std::vector<std::future<EvaluationResult>> lookaheadFutures;
|
||||
};
|
||||
|
||||
std::vector<CommandEvaluation> commandEvaluations(commandCount);
|
||||
@@ -330,12 +340,12 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
commandEvaluations[index].type = guessedCommandType;
|
||||
|
||||
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<ScoreValue> p;
|
||||
std::promise<EvaluationResult> p;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(p.get_future());
|
||||
p.set_value(currentUtility);
|
||||
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
|
||||
commandEvaluations[index].immediateScore = currentUtility;
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -347,14 +357,16 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
commandEvaluations[index].immediateScore = immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
commandEvaluations[index].immediateScore = evaluation.immediateScore;
|
||||
commandEvaluations[index].immediateCompleted = evaluation.completed;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(
|
||||
std::move(evaluation.lookaheadScore));
|
||||
} else if (guessedDescriptor->HasOdds()) {
|
||||
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
|
||||
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
|
||||
|
||||
// Success attempt uses 1.0 - (successChance / 2) as the roll
|
||||
auto [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
|
||||
auto successEvaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -368,7 +380,7 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
deadline);
|
||||
|
||||
// Failure attempt uses the average of (1 - successChance) and 0 as the roll
|
||||
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
|
||||
auto failureEvaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -381,24 +393,35 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
commandEvaluations[index].immediateScore =
|
||||
std::lerp(failureImmediateScore, successImmediateScore, successChance);
|
||||
commandEvaluations[index].immediateCompleted =
|
||||
failureEvaluation.completed && successEvaluation.completed;
|
||||
commandEvaluations[index].immediateScore = std::lerp(
|
||||
failureEvaluation.immediateScore,
|
||||
successEvaluation.immediateScore,
|
||||
successChance);
|
||||
|
||||
auto successSF = successLookaheadScore.share();
|
||||
auto failureSF = failureLookaheadScore.share();
|
||||
auto successSF = successEvaluation.lookaheadScore.share();
|
||||
auto failureSF = failureEvaluation.lookaheadScore.share();
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::async(
|
||||
std::launch::deferred,
|
||||
[successSF, failureSF, successChance]() -> double {
|
||||
return std::lerp(failureSF.get(), successSF.get(), successChance);
|
||||
[successSF, failureSF, successChance]() -> EvaluationResult {
|
||||
const auto failure = failureSF.get();
|
||||
const auto success = successSF.get();
|
||||
if (!failure.completed || !success.completed) {
|
||||
return EvaluationResult{.score = 0.0, .completed = false};
|
||||
}
|
||||
return EvaluationResult{
|
||||
.score = std::lerp(failure.score, success.score, successChance),
|
||||
.completed = true};
|
||||
}));
|
||||
} else {
|
||||
ScoreValue sum = 0.0;
|
||||
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
|
||||
const int sampleCount = std::max(1, maxRepeatCount);
|
||||
for (int repeatIteration = 0; repeatIteration < sampleCount; repeatIteration++) {
|
||||
// In each iteration, use a double from [0, 1] as the random roll
|
||||
auto sequence = std::vector{
|
||||
static_cast<double>(repeatIteration) /
|
||||
static_cast<double>(maxRepeatCount - 1)};
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
auto sequence =
|
||||
std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -410,10 +433,12 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
sum += immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
if (!evaluation.completed) { commandEvaluations[index].immediateCompleted = false; }
|
||||
sum += evaluation.immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(
|
||||
std::move(evaluation.lookaheadScore));
|
||||
}
|
||||
commandEvaluations[index].immediateScore = sum / maxRepeatCount;
|
||||
commandEvaluations[index].immediateScore = sum / sampleCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,19 +452,33 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
// Wait for all futures and compute final scores
|
||||
for (auto& eval : evals) {
|
||||
ScoreValue totalLookaheadScore = 0.0;
|
||||
bool completed = eval.immediateCompleted;
|
||||
for (auto& future : eval.lookaheadFutures) {
|
||||
totalLookaheadScore += future.get();
|
||||
const auto lookahead = future.get();
|
||||
if (!lookahead.completed) { completed = false; }
|
||||
totalLookaheadScore += lookahead.score;
|
||||
}
|
||||
ScoreValue avgLookaheadScore =
|
||||
eval.lookaheadFutures.empty()
|
||||
? eval.immediateScore
|
||||
: totalLookaheadScore / eval.lookaheadFutures.size();
|
||||
|
||||
if (!completed) { continue; }
|
||||
|
||||
allResults.push_back(IndexAndScore{
|
||||
.index = eval.index,
|
||||
.type = eval.type,
|
||||
.lookaheadScore = avgLookaheadScore,
|
||||
.immediateScore = eval.immediateScore});
|
||||
.immediateScore = eval.immediateScore,
|
||||
.completed = true});
|
||||
}
|
||||
if (allResults.empty()) {
|
||||
return IndexAndScore{
|
||||
.index = 0,
|
||||
.type = net::eagle0::shardok::common::UNKNOWN_COMMAND,
|
||||
.lookaheadScore = 0.0,
|
||||
.immediateScore = 0.0,
|
||||
.completed = false};
|
||||
}
|
||||
// Find the best command using the existing sorter
|
||||
auto bestIt = std::ranges::max_element(allResults, CommandSorter);
|
||||
@@ -457,12 +496,12 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
const ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
const size_t commandIndex,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult> {
|
||||
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
|
||||
|
||||
if (commandIndex >= guessedDescriptors->size()) {
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility);
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
@@ -470,11 +509,11 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
|
||||
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
|
||||
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility);
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
|
||||
return p.get_future();
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -485,13 +524,13 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
return std::move(lookaheadScore);
|
||||
return std::move(evaluation.lookaheadScore);
|
||||
} else if (guessedDescriptor->HasOdds()) {
|
||||
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
|
||||
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
|
||||
|
||||
// Success attempt
|
||||
auto [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
|
||||
auto successEvaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -504,7 +543,7 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
deadline);
|
||||
|
||||
// Failure attempt
|
||||
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
|
||||
auto failureEvaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -517,20 +556,29 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
deadline);
|
||||
|
||||
// Return weighted average of success and failure
|
||||
auto successSF = successLookaheadScore.share();
|
||||
auto failureSF = failureLookaheadScore.share();
|
||||
return std::async(std::launch::deferred, [successSF, failureSF, successChance]() -> double {
|
||||
return std::lerp(failureSF.get(), successSF.get(), successChance);
|
||||
});
|
||||
auto successSF = successEvaluation.lookaheadScore.share();
|
||||
auto failureSF = failureEvaluation.lookaheadScore.share();
|
||||
return std::async(
|
||||
std::launch::deferred,
|
||||
[successSF, failureSF, successChance]() -> EvaluationResult {
|
||||
const auto failure = failureSF.get();
|
||||
const auto success = successSF.get();
|
||||
if (!failure.completed || !success.completed) {
|
||||
return EvaluationResult{.score = 0.0, .completed = false};
|
||||
}
|
||||
return EvaluationResult{
|
||||
.score = std::lerp(failure.score, success.score, successChance),
|
||||
.completed = true};
|
||||
});
|
||||
} else {
|
||||
// For non-deterministic commands without odds, use multiple attempts
|
||||
std::vector<std::future<ScoreValue>> lookaheadFutures;
|
||||
lookaheadFutures.reserve(maxRepeatCount);
|
||||
std::vector<std::future<EvaluationResult>> lookaheadFutures;
|
||||
const int sampleCount = std::max(1, maxRepeatCount);
|
||||
lookaheadFutures.reserve(sampleCount);
|
||||
|
||||
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
|
||||
auto sequence = std::vector{
|
||||
static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1)};
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
for (int repeatIteration = 0; repeatIteration < sampleCount; repeatIteration++) {
|
||||
auto sequence = std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -542,17 +590,25 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
lookaheadFutures.push_back(std::move(evaluation.lookaheadScore));
|
||||
}
|
||||
|
||||
// Return a future that computes the average when needed
|
||||
return std::async(
|
||||
std::launch::deferred,
|
||||
[lookaheadFutures = std::move(lookaheadFutures),
|
||||
maxRepeatCount]() mutable -> double {
|
||||
sampleCount]() mutable -> EvaluationResult {
|
||||
ScoreValue total = 0.0;
|
||||
for (auto& future : lookaheadFutures) { total += future.get(); }
|
||||
return total / maxRepeatCount;
|
||||
bool completed = true;
|
||||
for (auto& future : lookaheadFutures) {
|
||||
const auto result = future.get();
|
||||
if (!result.completed) { completed = false; }
|
||||
total += result.score;
|
||||
}
|
||||
if (!completed) { return EvaluationResult{.score = 0.0, .completed = false}; }
|
||||
return EvaluationResult{
|
||||
.score = total / static_cast<ScoreValue>(sampleCount),
|
||||
.completed = true};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ public:
|
||||
BattalionTypeGetter battalionTypeGetter); // Pass by value
|
||||
|
||||
/// Evaluates the score for a particular command index with lookahead.
|
||||
struct EvaluationResult {
|
||||
ScoreValue score;
|
||||
bool completed;
|
||||
};
|
||||
|
||||
[[nodiscard]] auto EvaluateCommand(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
@@ -48,7 +53,7 @@ public:
|
||||
ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
size_t commandIndex,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult>;
|
||||
|
||||
/// Find the best command among all available commands at the given depth.
|
||||
struct IndexAndScore {
|
||||
@@ -56,6 +61,7 @@ public:
|
||||
CommandType type;
|
||||
ScoreValue lookaheadScore;
|
||||
ScoreValue immediateScore;
|
||||
bool completed;
|
||||
};
|
||||
|
||||
[[nodiscard]] auto FindBestCommand(
|
||||
@@ -76,7 +82,8 @@ private:
|
||||
|
||||
struct ImmediateAndLookaheadScore {
|
||||
ScoreValue immediateScore;
|
||||
std::future<ScoreValue> lookaheadScore;
|
||||
bool completed;
|
||||
std::future<EvaluationResult> lookaheadScore;
|
||||
};
|
||||
|
||||
/// Recursive lookahead calculator
|
||||
@@ -89,7 +96,7 @@ private:
|
||||
ScoreValue currentUtility,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult>;
|
||||
|
||||
/// Evaluate single command execution with randomness handling
|
||||
[[nodiscard]] auto EvaluateWithRandomness(
|
||||
|
||||
@@ -75,15 +75,47 @@ std::vector<size_t> AICommandFilter::FilterCommands(
|
||||
// Calculate minimum distance to enemies once for all filters
|
||||
const double minDistToEnemies = MinDistanceToEnemyUnits(gameState, pid, enemyLocations);
|
||||
|
||||
bool hasAvailablePlacementCommand = false;
|
||||
for (const auto& cmd : *commands) {
|
||||
if (cmd->GetCommandType() == CommandType::PLACE_UNIT_COMMAND ||
|
||||
cmd->GetCommandType() == CommandType::PLACE_HIDDEN_UNIT_COMMAND) {
|
||||
hasAvailablePlacementCommand = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool playerHasReserveUnits = false;
|
||||
for (const Unit* unit : *gameState->units()) {
|
||||
if (unit->player_id() == pid &&
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT) {
|
||||
playerHasReserveUnits = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < commands->size(); ++i) {
|
||||
const auto& cmd = (*commands)[i];
|
||||
|
||||
if (cmd->GetCommandType() == CommandType::END_PLAYER_SETUP_COMMAND &&
|
||||
hasAvailablePlacementCommand && playerHasReserveUnits) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always allow END_TURN commands
|
||||
if (cmd->GetCommandType() == CommandType::END_TURN_COMMAND) {
|
||||
filteredIndices.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Setup placement commands should always remain available. The movement/spell heuristics
|
||||
// below are combat-oriented and can otherwise filter every placement for an in-reserve
|
||||
// unit, making MCTS think ending setup is the only good option.
|
||||
if (cmd->GetCommandType() == CommandType::PLACE_UNIT_COMMAND ||
|
||||
cmd->GetCommandType() == CommandType::PLACE_HIDDEN_UNIT_COMMAND) {
|
||||
filteredIndices.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter obviously bad moves
|
||||
bool shouldFilter = false;
|
||||
|
||||
@@ -423,6 +455,35 @@ bool AICommandFilter::IsWastefulAction(
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::FEAR_COMMAND: {
|
||||
// Solo attackers gain nothing from Fear: the target is stunned for a round but
|
||||
// there is no teammate to capitalize, and the caster ends its turn no closer to
|
||||
// killing units or capturing castles. Defenders, by contrast, benefit from stalling,
|
||||
// so this restriction only applies to the attacking side.
|
||||
//
|
||||
// Exception: if the victim is standing on a fire tile, stunning locks them in place
|
||||
// while the fire modifier keeps chewing through their unit value — the burn does the
|
||||
// work that a follow-up attacker would normally provide.
|
||||
if (isDefender) { break; }
|
||||
if (CountPlayerUnits(gameState, pid) > 1) { break; }
|
||||
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"FEAR_COMMAND missing required target information");
|
||||
}
|
||||
|
||||
const Coords victimLocation(
|
||||
static_cast<int8_t>(targetRow),
|
||||
static_cast<int8_t>(targetCol));
|
||||
const auto* victimTerrain = GetTerrain(gameState->hex_map(), victimLocation);
|
||||
if (!victimTerrain->modifier().fire().present()) {
|
||||
return true; // Solo attacker fear with no fire-follow-up is a wasted action
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::EXTINGUISH_FIRE_COMMAND: {
|
||||
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
@@ -639,4 +700,4 @@ bool AICommandFilter::WouldAbandonCriticalCastle(
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
} // namespace shardok
|
||||
|
||||
@@ -15,11 +15,11 @@ enum class AIAlgorithmType {
|
||||
|
||||
// Enum for scoring calculator selection
|
||||
enum class ScoringCalculatorType {
|
||||
STANDARD, // Default: Unbounded raw scores
|
||||
NORMALIZED, // Normalized scores in [0, 1] range for ML training
|
||||
MCTS_OPTIMIZED // Bounded linear scores tuned for MCTS
|
||||
STANDARD, // Default: Unbounded raw scores
|
||||
MCTS_OPTIMIZED, // Bounded linear scores tuned for MCTS
|
||||
EXPERIMENTAL // Standard-derived scorer selected by experiment id
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_CONFIG_HPP
|
||||
#endif // EAGLE0_AI_CONFIG_HPP
|
||||
|
||||
@@ -84,6 +84,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
|
||||
// * the defender has way, way fewer troops
|
||||
// other considerations we should have but do not presently:
|
||||
// * the attacker is close to the defender / castles
|
||||
|
||||
if (canFlee && roundsRemaining > 2 &&
|
||||
defenderTroops < MAXIMUM_RATIO_FOR_DEFENDER_TO_FLEE * attackerTroops &&
|
||||
attackerNonUndeadUnitNotRequiringWaterCrossingCount >= criticalTileCoords.size()) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Temporary AI experiment switches used by benchmark-only EXPERIMENTAL runs.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AI_EXPERIMENT_CONFIG_HPP
|
||||
#define EAGLE0_AI_EXPERIMENT_CONFIG_HPP
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
|
||||
inline auto CurrentAIExperimentId() -> int {
|
||||
const char* rawValue = std::getenv("SHARDOK_SCORING_EXPERIMENT_ID");
|
||||
if (rawValue == nullptr || std::string(rawValue).empty()) { return 0; }
|
||||
return std::atoi(rawValue);
|
||||
}
|
||||
|
||||
inline auto IsAIExperiment(const int id) -> bool { return CurrentAIExperimentId() == id; }
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_EXPERIMENT_CONFIG_HPP
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "AIUnitScoreCalculator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
@@ -33,7 +32,9 @@ constexpr double kMeteorCastRoundMultiplier = 1.5;
|
||||
constexpr double kMeteorCastInsufficientVigorMultiplier = 0.5;
|
||||
constexpr double kMeteorCastleMultiplier = 2.0;
|
||||
constexpr double kLightningPossibleValue = 0.05;
|
||||
constexpr double kArcheryPossibleValue = 38;
|
||||
constexpr double kExpectedArcheryVolleyTargetValueFraction = 0.16;
|
||||
constexpr double kImmediateArcheryThreatMultiplier = 0.5;
|
||||
constexpr double kFutureArcheryThreatMultiplier = 0.25;
|
||||
constexpr double kReduceFortifiedPossibleValue = 50;
|
||||
constexpr double kReduceUnfortifiedPossibleValue = 12;
|
||||
constexpr double kFearPossibleValue = 38;
|
||||
@@ -101,9 +102,24 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
|
||||
return battalionValue + heroValue;
|
||||
}
|
||||
|
||||
auto archeryValue(const Unit * /*unit*/) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
return kArcheryPossibleValue;
|
||||
auto bestEnemyUnitValue(const vector<const Unit *> &enemyUnits) -> double {
|
||||
double bestTargetValue = 0.0;
|
||||
for (const Unit *enemyUnit : enemyUnits) {
|
||||
bestTargetValue = std::max(bestTargetValue, ContextFreeUnitValue(enemyUnit));
|
||||
}
|
||||
return bestTargetValue;
|
||||
}
|
||||
|
||||
auto expectedImpactArcheryValue(const vector<const Unit *> &enemyUnits, const bool canShootNow)
|
||||
-> double {
|
||||
const double expectedVolleyImpact =
|
||||
kExpectedArcheryVolleyTargetValueFraction * bestEnemyUnitValue(enemyUnits);
|
||||
return expectedVolleyImpact *
|
||||
(canShootNow ? kImmediateArcheryThreatMultiplier : kFutureArcheryThreatMultiplier);
|
||||
}
|
||||
|
||||
auto archeryValue(const vector<const Unit *> &enemyUnits, const bool canShootNow) -> double {
|
||||
return expectedImpactArcheryValue(enemyUnits, canShootNow);
|
||||
}
|
||||
|
||||
auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
|
||||
@@ -116,10 +132,7 @@ auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
auto fearValue(const Unit * /*unit*/) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
return kFearPossibleValue;
|
||||
}
|
||||
auto fearValue(const vector<const Unit *> &) -> double { return kFearPossibleValue; }
|
||||
|
||||
auto lightningValue(const Unit *unit) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
@@ -265,11 +278,13 @@ auto GetRangedAttackBonus(
|
||||
const vector<const Unit *> &attackerUnits,
|
||||
const vector<const Unit *> &defenderUnits,
|
||||
const int meteorRange,
|
||||
const double minVigorToCast) -> double {
|
||||
const double minVigorToCast,
|
||||
const ActionPoints archeryActionPointCost) -> double {
|
||||
vector<double> rangedAttackValues{};
|
||||
|
||||
const auto &enemyUnits = isAttacker ? defenderUnits : attackerUnits;
|
||||
const auto &friendlyUnits = isAttacker ? attackerUnits : defenderUnits;
|
||||
const bool canShootNow = unit->remaining_action_points() >= archeryActionPointCost;
|
||||
|
||||
const auto &unitLocation = unit->location();
|
||||
|
||||
@@ -306,19 +321,19 @@ auto GetRangedAttackBonus(
|
||||
unit->attached_hero().profession_info().profession() ==
|
||||
net::eagle0::shardok::storage::fb::Profession_NECROMANCER &&
|
||||
unit->attached_hero().control_info().controlled_unit_id() != -1) {
|
||||
rangedAttackValues.push_back(fearValue(unit));
|
||||
rangedAttackValues.push_back(fearValue(enemyUnits));
|
||||
}
|
||||
|
||||
if (attackLocations.ArcheryLocations().Contains(unitLocation) &&
|
||||
unit->volleys_remaining() > 0) {
|
||||
rangedAttackValues.push_back(archeryValue(unit));
|
||||
rangedAttackValues.push_back(archeryValue(enemyUnits, canShootNow));
|
||||
}
|
||||
|
||||
// Longbowmen in castle can archer adjacent
|
||||
if (attackLocations.AdjacentLocations().Contains(unitLocation) &&
|
||||
unit->volleys_remaining() > 0 && terrain->modifier().castle().present() &&
|
||||
unit->battalion().type() == net::eagle0::shardok::storage::fb::BattalionTypeId_LONGBOWMEN) {
|
||||
rangedAttackValues.push_back(archeryValue(unit));
|
||||
rangedAttackValues.push_back(archeryValue(enemyUnits, canShootNow));
|
||||
}
|
||||
|
||||
if (rangedAttackValues.empty()) return 0.0;
|
||||
@@ -338,7 +353,8 @@ auto UnitValue(
|
||||
const CoordsSet &locationsInDangerFromEnemy,
|
||||
const ActionPointDistances *distances,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost) -> ScoreValue {
|
||||
double meteorCastVigorCost,
|
||||
ActionPoints archeryActionPointCost) -> ScoreValue {
|
||||
const auto &location = unit->location();
|
||||
if (location.row() < 0) return 0; // unplaced unit
|
||||
|
||||
@@ -382,7 +398,8 @@ auto UnitValue(
|
||||
attackerUnits,
|
||||
defenderUnits,
|
||||
meteorRange,
|
||||
meteorCastVigorCost);
|
||||
meteorCastVigorCost,
|
||||
archeryActionPointCost);
|
||||
|
||||
// scouting values
|
||||
// attack range
|
||||
|
||||
@@ -32,7 +32,8 @@ auto GetRangedAttackBonus(
|
||||
const vector<const Unit *> &attackerUnits,
|
||||
const vector<const Unit *> &defenderUnits,
|
||||
int meteorRange,
|
||||
double minVigorToCast) -> double;
|
||||
double minVigorToCast,
|
||||
ActionPoints archeryActionPointCost = 0) -> double;
|
||||
|
||||
auto UnitValue(
|
||||
const Unit *unit,
|
||||
@@ -47,7 +48,8 @@ auto UnitValue(
|
||||
const CoordsSet &locationsInDangerFromEnemy,
|
||||
const ActionPointDistances *distances,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost) -> ScoreValue;
|
||||
double meteorCastVigorCost,
|
||||
ActionPoints archeryActionPointCost = 0) -> ScoreValue;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -156,7 +156,9 @@ The system uses recursive lookahead with:
|
||||
- `kProfessionValue`: 200
|
||||
|
||||
### Ranged Attack Values
|
||||
- `kArcheryPossibleValue`: 38
|
||||
- Archery position value: expected-impact based. A unit that can shoot now gets half of
|
||||
`0.16 * best target unit value`; a unit in archery range that cannot shoot yet gets one
|
||||
quarter of that expected volley value.
|
||||
- `kMeteorDirectTargetingEnemy`: 2 per soldier
|
||||
- `kMeteorSplashTargetingEnemy`: 1 per soldier
|
||||
- `kLightningPossibleValue`: 0.05 per soldier
|
||||
@@ -762,4 +764,4 @@ MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache, confi
|
||||
- Higher `immediateScoreTieBreakThreshold` to emphasize direct paths
|
||||
- `BEST_IMMEDIATE` simulation for most predictable behavior
|
||||
|
||||
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
|
||||
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
|
||||
|
||||
@@ -11,6 +11,13 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_experiment_config",
|
||||
hdrs = ["AIExperimentConfig.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok/ai:__subpackages__"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_attacker_strategy_selector",
|
||||
srcs = ["AIAttackerStrategySelector.cpp"],
|
||||
@@ -84,6 +91,7 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
":ai_experiment_config",
|
||||
":ai_score_utilities",
|
||||
":ai_strategy",
|
||||
":ai_water_crossing_calculator",
|
||||
@@ -196,6 +204,7 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_command_filter",
|
||||
":ai_experiment_config",
|
||||
":ai_strategy",
|
||||
":transposition_table",
|
||||
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
|
||||
@@ -272,6 +281,7 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
":ai_experiment_config",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
|
||||
],
|
||||
@@ -348,6 +358,7 @@ cc_library(
|
||||
":ai_attacker_strategy_selector",
|
||||
":ai_command_evaluator",
|
||||
":ai_defender_strategy_selector",
|
||||
":ai_experiment_config",
|
||||
":ai_time_budget",
|
||||
":ai_water_crossing_command_chooser",
|
||||
"//src/main/cpp/net/eagle0/common:time_utils",
|
||||
@@ -385,7 +396,6 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:shardok_mcts_ai", # MCTS with abstraction layer
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator", # Bounded linear scorer for MCTS
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:normalized_ai_score_calculator", # Normalized [0,1] scorer for ML training
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator", # Standard unbounded scorer (default)
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "AICommandEvaluator.hpp"
|
||||
#include "AICommandFilter.hpp"
|
||||
#include "TranspositionTable.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIExperimentConfig.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
|
||||
@@ -130,6 +131,10 @@ auto IterativeDeepeningAI::IterativeSearch(
|
||||
// Now wait for all futures and collect results
|
||||
for (auto& [cmdIndex, future] : futures) {
|
||||
auto cmdResult = future.get();
|
||||
if (!cmdResult.searchCompleted) {
|
||||
allEvaluated = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure scoresByDepth[cmdIndex] has enough space
|
||||
if (scoresByDepth[cmdIndex].size() <= currentDepth) {
|
||||
@@ -221,7 +226,7 @@ auto IterativeDeepeningAI::IterativeSearch(
|
||||
}
|
||||
|
||||
// If all evaluated commands had unchanged scores, we've hit END_TURN in lookahead
|
||||
if (scoresUnchanged && unchangedCount == evaluatedCount) {
|
||||
if (!IsAIExperiment(26) && scoresUnchanged && unchangedCount == evaluatedCount) {
|
||||
completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
|
||||
break;
|
||||
}
|
||||
@@ -251,7 +256,9 @@ auto IterativeDeepeningAI::IterativeSearch(
|
||||
completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
|
||||
}
|
||||
|
||||
// Select best result from highest depth achieved for each command
|
||||
// Select best result from the highest completed depth achieved for each command. Commands that
|
||||
// timed out at a depth are not recorded for that depth, so partial deeper work can help without
|
||||
// letting timeout fallback scores compete.
|
||||
result = SelectBestResult(scoresByDepth, highestDepthCompleted);
|
||||
result.minimumDepthCompleted = result.depthAchieved >= timeBudget.minDepthRequired;
|
||||
result.searchCompleted = result.minimumDepthCompleted;
|
||||
@@ -342,7 +349,9 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
|
||||
// Deduct adjusted time from remaining budget
|
||||
timeBudget.remainingBudget -= adjustedElapsedMs;
|
||||
|
||||
result.bestScore = commandScore;
|
||||
result.bestScore = commandScore.score;
|
||||
result.searchCompleted = commandScore.completed;
|
||||
result.minimumDepthCompleted = commandScore.completed;
|
||||
|
||||
std::promise<SearchResult> p;
|
||||
p.set_value(result);
|
||||
@@ -355,7 +364,7 @@ auto IterativeDeepeningAI::GetCommandsSortedByPreviousDepth(
|
||||
const std::vector<size_t>& highestDepthCompleted,
|
||||
const std::vector<size_t>& filteredIndices) -> std::vector<size_t> {
|
||||
if (currentDepth == 1) {
|
||||
// For depth 1, return filtered indices in natural order
|
||||
// For depth 1, preserve the upstream command-factory priority order.
|
||||
return filteredIndices;
|
||||
}
|
||||
|
||||
@@ -392,10 +401,20 @@ auto IterativeDeepeningAI::SelectBestResult(
|
||||
result.bestScore = -std::numeric_limits<ScoreValue>::infinity();
|
||||
result.searchCompleted = false;
|
||||
|
||||
size_t selectionDepth = 0;
|
||||
if (IsAIExperiment(24)) {
|
||||
selectionDepth = std::numeric_limits<size_t>::max();
|
||||
for (const size_t depth : highestDepthCompleted) {
|
||||
if (depth > 0) { selectionDepth = std::min(selectionDepth, depth); }
|
||||
}
|
||||
if (selectionDepth == std::numeric_limits<size_t>::max()) { selectionDepth = 0; }
|
||||
}
|
||||
|
||||
// Find the command with best score at its highest evaluated depth
|
||||
for (size_t i = 0; i < scoresByDepth.size(); ++i) {
|
||||
if (highestDepthCompleted[i] > 0) {
|
||||
const size_t depth = highestDepthCompleted[i];
|
||||
const size_t depth = selectionDepth > 0 ? selectionDepth : highestDepthCompleted[i];
|
||||
if (highestDepthCompleted[i] < depth || scoresByDepth[i].size() <= depth) { continue; }
|
||||
if (ScoreValue score = scoresByDepth[i][depth]; score > result.bestScore) {
|
||||
result.bestScore = score;
|
||||
result.bestCommandIndex = i;
|
||||
@@ -407,4 +426,4 @@ auto IterativeDeepeningAI::SelectBestResult(
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
} // namespace shardok
|
||||
|
||||
@@ -111,4 +111,4 @@ private:
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
|
||||
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
#include "IterativeDeepeningAI.hpp"
|
||||
#include "mcts/ShardokMCTSAI.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/MCTSOptimizedAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/NormalizedAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
|
||||
@@ -299,12 +298,12 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
// Create scorer for actual scoring during search - type selected at construction
|
||||
std::unique_ptr<AIScoreCalculator> scorer;
|
||||
switch (scoringCalculatorType) {
|
||||
case ScoringCalculatorType::NORMALIZED:
|
||||
scorer = MakeNormalizedAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::MCTS_OPTIMIZED:
|
||||
scorer = MakeMCTSOptimizedAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::EXPERIMENTAL:
|
||||
scorer = MakeExperimentalAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::STANDARD:
|
||||
default: scorer = MakeStandardAIScoreCalculator(settingsGetter, apdCache, alCache); break;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,22 @@
|
||||
|
||||
namespace shardok::mcts {
|
||||
|
||||
namespace {
|
||||
|
||||
auto IsDefenderPlayer(const GameStateW& gameState, const PlayerId playerId) -> bool {
|
||||
for (const auto* pi : *gameState->player_infos()) {
|
||||
if (pi->player_id() == playerId) { return pi->is_defender(); }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
auto LegalActionsCacheKey(const uint64_t stateHash, const bool rootIsDefender) -> uint64_t {
|
||||
constexpr uint64_t kDefenderRoleHash = 0x9e3779b97f4a7c15ULL;
|
||||
return rootIsDefender ? stateHash ^ kDefenderRoleHash : stateHash;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Shared cache for legal actions (uses lock-free parallel hash map for thread safety)
|
||||
// Using 8 submaps to reduce contention with 16 MCTS threads
|
||||
gtl::parallel_flat_hash_map<
|
||||
@@ -223,13 +239,14 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
// Time hash computation
|
||||
const auto hashStart = std::chrono::high_resolution_clock::now();
|
||||
const uint64_t stateHash = shardokState->hash();
|
||||
const uint64_t cacheKey = LegalActionsCacheKey(stateHash, isDefender_);
|
||||
const auto hashEnd = std::chrono::high_resolution_clock::now();
|
||||
timeInHashComputation_.fetch_add(
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count(),
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// Check transposition table for cached legal actions
|
||||
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
|
||||
if (auto it = legalActionsCache_.find(cacheKey); it != legalActionsCache_.end()) {
|
||||
cacheHits_.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
// Use cached engine
|
||||
@@ -304,7 +321,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
|
||||
commands,
|
||||
currentPlayer,
|
||||
isDefender_,
|
||||
IsDefenderPlayer(shardokState->getShardokState(), currentPlayer),
|
||||
shardokState->getShardokState(),
|
||||
*apdCache_,
|
||||
[this](BattalionTypeId typeId) {
|
||||
@@ -362,7 +379,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
// This avoids duplicating heavy protocol buffer objects
|
||||
// Use lazy_emplace_l to ensure thread-safe insertion (locks the bucket during construction)
|
||||
legalActionsCache_.lazy_emplace_l(
|
||||
stateHash,
|
||||
cacheKey,
|
||||
[&](typename decltype(legalActionsCache_)::value_type& v) {
|
||||
// Update existing entry
|
||||
v.second.filteredIndices = filteredIndices;
|
||||
@@ -370,7 +387,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
},
|
||||
[&](const typename decltype(legalActionsCache_)::constructor& ctor) {
|
||||
// Create new entry
|
||||
ctor(stateHash, LegalActionsCache{filteredIndices, engine});
|
||||
ctor(cacheKey, LegalActionsCache{filteredIndices, engine});
|
||||
});
|
||||
|
||||
return actions;
|
||||
@@ -416,14 +433,8 @@ std::vector<double> ShardokGameEngine::getActionWeights(
|
||||
|
||||
// Determine if current player is defender (not root player!)
|
||||
// During simulation we need to use the correct perspective for action weighting
|
||||
bool currentPlayerIsDefender = false;
|
||||
const auto& gameState = shardokState->getShardokState();
|
||||
for (const auto* pi : *gameState->player_infos()) {
|
||||
if (pi->player_id() == currentPlayer) {
|
||||
currentPlayerIsDefender = pi->is_defender();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const bool currentPlayerIsDefender = IsDefenderPlayer(gameState, currentPlayer);
|
||||
|
||||
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
|
||||
std::vector<double> weights;
|
||||
|
||||
@@ -110,8 +110,19 @@ bool ShardokGameState::equals(const MCTSGameState& other) const {
|
||||
}
|
||||
|
||||
MCTSPlayerId ShardokGameState::getWinner() const {
|
||||
// Note: FlatBuffer doesn't have a winner field
|
||||
// In practice, this would need to determine winner from victory conditions
|
||||
if (!state_->status() ||
|
||||
state_->status()->state() !=
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const auto* winningIds = state_->status()->winning_shardok_ids();
|
||||
if (!winningIds || winningIds->empty()) { return -1; }
|
||||
|
||||
for (const PlayerId winningPid : *winningIds) {
|
||||
if (winningPid >= 0) { return winningPid; }
|
||||
}
|
||||
|
||||
return -1; // No winner
|
||||
}
|
||||
|
||||
|
||||
@@ -41,31 +41,6 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "normalized_ai_score_calculator",
|
||||
srcs = ["NormalizedAIScoreCalculator.cpp"],
|
||||
hdrs = ["NormalizedAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:abstract_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:ai_score_calculator_shared_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "standard_ai_score_calculator",
|
||||
srcs = ["StandardAIScoreCalculator.cpp"],
|
||||
@@ -79,6 +54,7 @@ cc_library(
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_experiment_config",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
|
||||
@@ -59,6 +59,7 @@ public:
|
||||
MCTSOptimizedAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
ActionPoints archeryActionPointCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
@@ -69,6 +70,7 @@ public:
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
archeryActionPointCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
@@ -236,6 +238,7 @@ auto MakeMCTSOptimizedAIScoreCalculator(
|
||||
return std::make_unique<MCTSOptimizedAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().archery_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
We need a scoring algorithm that makes MCTS perform well by providing score differences in the right range:
|
||||
|
||||
- **Standard Scorer**: Returns unbounded relative scores. Small differences get amplified in MCTS UCB formula, causing over-exploitation (commits to 1-2 high-scoring nodes too early).
|
||||
- **Normalized Scorer**: Returns scores in [0,1] range with power transformation (exponent=0.1). Differences are too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI makes bad choices).
|
||||
- **Retired bounded-ratio scorer**: Returned scores in [0,1] range with power transformation (exponent=0.1). Differences were too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI made bad choices).
|
||||
|
||||
### MCTS Requirements
|
||||
|
||||
@@ -32,9 +32,9 @@ This means individual scores should differ by **0.5 to 2.0** between meaningfull
|
||||
- Range: 0 (all captured) to -3000 (all held, far away)
|
||||
|
||||
### Terminal States
|
||||
- Victory: INT_MAX (or 1.0 for normalized)
|
||||
- Defeat: INT_MIN (or 0.0 for normalized)
|
||||
- Flee/Draw: 0 (or 0.5 for normalized)
|
||||
- Victory: INT_MAX
|
||||
- Defeat: INT_MIN
|
||||
- Flee/Draw: 0
|
||||
- Captured unit: -10,000
|
||||
- Captured VIP: -25,000
|
||||
|
||||
@@ -257,7 +257,7 @@ This progression is ideal:
|
||||
|
||||
This avoids both pathologies:
|
||||
- Not over-exploiting (like Standard scorer which overcommitted to tiny early differences)
|
||||
- Not over-exploring (like Normalized scorer which explored equally even with large advantages)
|
||||
- Not over-exploring like the retired bounded-ratio scorer, which explored equally even with large advantages
|
||||
|
||||
## Why MCTS Needs Stronger Signal Than Minimax
|
||||
|
||||
@@ -281,11 +281,11 @@ This gives MCTS the right balance: explore when moves are similar, exploit when
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard and Normalized
|
||||
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard
|
||||
2. **Reuse unit scoring**: Use existing CalculateUnitsScoreComponents and victory condition calculators
|
||||
3. **Only change combination**: Override CombineAttackerScores, CombineDefenderScores, etc.
|
||||
4. **Bounded terminals**: Use ±1000 instead of INT_MAX/MIN for numerical stability
|
||||
5. **No transformation**: Unlike Normalized, don't apply power transformation - linear scaling is sufficient
|
||||
5. **No transformation**: Do not apply power transformation - linear scaling is sufficient
|
||||
6. **Scale constants tuned for MCTS**: 10x larger than naive normalization to provide appropriate signal strength
|
||||
|
||||
## Testing with Integration Tests
|
||||
@@ -383,6 +383,5 @@ Once the scorer passes integration tests with IterativeDeepeningAI, then integra
|
||||
|
||||
- `BoundedLinearAIScoreCalculator`
|
||||
- `MCTSOptimizedAIScoreCalculator`
|
||||
- `LinearNormalizedAIScoreCalculator`
|
||||
|
||||
Recommend: **`MCTSOptimizedAIScoreCalculator`** to clearly indicate purpose.
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
//
|
||||
// Normalized [0,1] implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#include "NormalizedAIScoreCalculator.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
// Bring shared utilities into scope
|
||||
using score_calculator_internal::UnitsScoreComponents;
|
||||
|
||||
/// Normalized implementation of AIScoreCalculator that produces scores in [0, 1] range.
|
||||
/// Inherits from AbstractAIScoreCalculator to share common functionality.
|
||||
class NormalizedAIScoreCalculator : public AbstractAIScoreCalculator {
|
||||
public:
|
||||
NormalizedAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue override;
|
||||
|
||||
// Implement pure virtual methods from AbstractAIScoreCalculator
|
||||
[[nodiscard]] auto InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
[[nodiscard]] auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderScatterScores(const UnitsScoreComponents &components) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
/// Applies power transformation to spread out compressed scores for MCTS.
|
||||
/// Maps [0,1] → [0,1] but pushes values away from 0.5 toward the extremes.
|
||||
/// Terminal states (0.0, 1.0) are unchanged.
|
||||
[[nodiscard]] auto TransformForMCTS(ScoreValue score) const -> ScoreValue;
|
||||
};
|
||||
|
||||
// Implementation of NormalizedAIScoreCalculator methods
|
||||
|
||||
auto NormalizedAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return 1.0;
|
||||
case GameOutcome::ATTACKER_VICTORY: return 0.0;
|
||||
case GameOutcome::DRAW: return 0.5;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.5;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return 1.0;
|
||||
case GameOutcome::DEFENDER_VICTORY: return 0.0;
|
||||
case GameOutcome::DRAW: return 0.5;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.5;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue {
|
||||
// For defender, we flip the perspective: defenderValue is (1), attackerValue is (2)
|
||||
const double defenderValue = components.defenderUnitsValue;
|
||||
const double attackerValue = components.attackerUnitsValue;
|
||||
|
||||
// No victory condition for scatter strategy
|
||||
const double denominator = defenderValue + attackerValue;
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return defenderValue / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int /*roundsRemaining*/) const -> ScoreValue {
|
||||
// For defender, flip perspective
|
||||
const double defenderValue = components.defenderUnitsValue;
|
||||
const double attackerValue = components.attackerUnitsValue;
|
||||
|
||||
// Apply normalization
|
||||
double numerator;
|
||||
double denominator;
|
||||
|
||||
if (victoryConditionScore >= 0) {
|
||||
numerator = defenderValue + victoryConditionScore;
|
||||
denominator = defenderValue + attackerValue + victoryConditionScore;
|
||||
} else {
|
||||
numerator = defenderValue;
|
||||
denominator = defenderValue + attackerValue - victoryConditionScore;
|
||||
}
|
||||
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::DefenderFleeStrategyScoreForState(
|
||||
const GameStateW & /*gameState*/) const -> ScoreValue {
|
||||
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::AttackerFleeStrategyScoreForState(
|
||||
const GameStateW & /*gameState*/) const -> ScoreValue {
|
||||
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int /*roundsRemaining*/) const -> ScoreValue {
|
||||
const double attackerUnitsValue = components.attackerUnitsValue;
|
||||
const double defenderUnitsValue = components.defenderUnitsValue;
|
||||
|
||||
// Apply normalization formula
|
||||
double numerator;
|
||||
double denominator;
|
||||
|
||||
if (victoryConditionScore >= 0) {
|
||||
// Positive victory condition: add to numerator
|
||||
numerator = attackerUnitsValue + victoryConditionScore;
|
||||
denominator = attackerUnitsValue + defenderUnitsValue + victoryConditionScore;
|
||||
} else {
|
||||
// Negative victory condition: subtract from denominator (making it larger)
|
||||
numerator = attackerUnitsValue;
|
||||
denominator = attackerUnitsValue + defenderUnitsValue - victoryConditionScore;
|
||||
}
|
||||
|
||||
// Handle edge case of all zeros
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::TransformForMCTS(ScoreValue score) const -> ScoreValue {
|
||||
// Power transformation exponent - lower values spread scores more toward extremes
|
||||
// Tuned for MCTS: balances exploration vs exploitation
|
||||
// - Too low (e.g., 0.3): over-exploitation like standard scorer
|
||||
// - Too high (e.g., 0.9): over-exploration like untransformed normalized
|
||||
// - 0.6-0.7: sweet spot for MCTS
|
||||
constexpr double EXPONENT = 0.1;
|
||||
|
||||
if (score > 0.5) {
|
||||
// Map [0.5, 1.0] → [0.5, 1.0] with power curve
|
||||
// (score - 0.5) * 2.0 maps to [0, 1], apply power, then scale back
|
||||
return 0.5 + 0.5 * std::pow((score - 0.5) * 2.0, EXPONENT);
|
||||
} else {
|
||||
// Map [0.0, 0.5] → [0.0, 0.5] with power curve (symmetric)
|
||||
return 0.5 - 0.5 * std::pow((0.5 - score) * 2.0, EXPONENT);
|
||||
}
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue {
|
||||
const int roundsRemaining = GetMaxRounds() - state->current_round();
|
||||
|
||||
ScoreValue rawScore;
|
||||
if (isDefender) {
|
||||
rawScore = DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
} else {
|
||||
rawScore = AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
|
||||
// Apply power transformation to spread out scores for MCTS
|
||||
return TransformForMCTS(rawScore);
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeNormalizedAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
typeId <= BattalionTypeId::BattalionTypeId_MAX;
|
||||
typeId++) {
|
||||
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
|
||||
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
|
||||
}
|
||||
|
||||
return std::make_unique<NormalizedAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// Normalized [0,1] implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
#define EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Forward declarations
|
||||
class AIScoreCalculator;
|
||||
|
||||
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
|
||||
using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
|
||||
/// Factory function to create a NormalizedAIScoreCalculator.
|
||||
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
|
||||
///
|
||||
/// The normalized scorer produces scores in the range [0, 1] where:
|
||||
/// - 0.0 = complete defender victory
|
||||
/// - 1.0 = complete attacker victory
|
||||
/// - 0.5 = neutral/draw state
|
||||
///
|
||||
/// Terminal states (victory/defeat) always return 1.0 or 0.0.
|
||||
/// Non-terminal states use asymmetric normalization:
|
||||
/// - If victory condition >= 0:
|
||||
/// score = (attackerUnits + victoryCondition) / (attackerUnits + defenderUnits +
|
||||
/// victoryCondition)
|
||||
/// - If victory condition < 0:
|
||||
/// score = attackerUnits / (attackerUnits + defenderUnits - victoryCondition)
|
||||
[[nodiscard]] auto MakeNormalizedAIScoreCalculator(
|
||||
const SettingsGetter& settingsGetter,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
@@ -4,14 +4,17 @@
|
||||
|
||||
#include "StandardAIScoreCalculator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIExperimentConfig.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
@@ -67,6 +70,68 @@ struct AttackerScorePerformanceLogger {
|
||||
}
|
||||
};
|
||||
|
||||
struct StandardScoringExperiment {
|
||||
double attackerUnitsScale = 1.0;
|
||||
double defenderUnitsScale = 1.0;
|
||||
double victoryConditionScale = 1.0;
|
||||
double unitsBaseMultiplierScale = 1.0;
|
||||
double attackerRoundMultiplier = 1.0;
|
||||
double defenderRoundAdvantageScale = 1.0;
|
||||
};
|
||||
|
||||
auto ExperimentalScoringId() -> int { return CurrentAIExperimentId(); }
|
||||
|
||||
auto ScoringExperimentForId(const int id) -> StandardScoringExperiment {
|
||||
StandardScoringExperiment experiment;
|
||||
|
||||
switch (id) {
|
||||
case 1: experiment.attackerUnitsScale = 1.05; break;
|
||||
case 2: experiment.attackerUnitsScale = 1.10; break;
|
||||
case 3: experiment.attackerUnitsScale = 0.95; break;
|
||||
case 4: experiment.attackerUnitsScale = 0.90; break;
|
||||
case 5: experiment.defenderUnitsScale = 0.95; break;
|
||||
case 6: experiment.defenderUnitsScale = 0.90; break;
|
||||
case 7: experiment.defenderUnitsScale = 1.05; break;
|
||||
case 8: experiment.defenderUnitsScale = 1.10; break;
|
||||
case 9: experiment.victoryConditionScale = 1.10; break;
|
||||
case 10: experiment.victoryConditionScale = 1.25; break;
|
||||
case 11: experiment.victoryConditionScale = 0.90; break;
|
||||
case 12: experiment.victoryConditionScale = 0.75; break;
|
||||
case 13: experiment.unitsBaseMultiplierScale = 0.90; break;
|
||||
case 14: experiment.unitsBaseMultiplierScale = 1.10; break;
|
||||
case 15: experiment.attackerRoundMultiplier = 0.90; break;
|
||||
case 16: experiment.attackerRoundMultiplier = 0.75; break;
|
||||
case 17: experiment.defenderRoundAdvantageScale = 0.50; break;
|
||||
case 18: experiment.defenderRoundAdvantageScale = 0.0; break;
|
||||
case 19:
|
||||
experiment.attackerUnitsScale = 1.10;
|
||||
experiment.victoryConditionScale = 1.10;
|
||||
break;
|
||||
case 20:
|
||||
experiment.defenderUnitsScale = 0.90;
|
||||
experiment.victoryConditionScale = 1.10;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
auto AdjustDefenderUnitsForExperiment(
|
||||
double defenderUnitsValue,
|
||||
const int roundsRemaining,
|
||||
const int maxRounds,
|
||||
const StandardScoringExperiment &experiment) -> double {
|
||||
const double currentRound = static_cast<double>(maxRounds - roundsRemaining);
|
||||
const double standardAdvantage = 1.0 + currentRound / 31.0;
|
||||
if (standardAdvantage <= 0.0) { return defenderUnitsValue * experiment.defenderUnitsScale; }
|
||||
|
||||
const double unadjustedDefenderValue = defenderUnitsValue / standardAdvantage;
|
||||
const double adjustedAdvantage =
|
||||
1.0 + experiment.defenderRoundAdvantageScale * currentRound / 31.0;
|
||||
return unadjustedDefenderValue * adjustedAdvantage * experiment.defenderUnitsScale;
|
||||
}
|
||||
|
||||
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
|
||||
@@ -95,23 +160,27 @@ public:
|
||||
StandardAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
ActionPoints archeryActionPointCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
const ALCache &alCache,
|
||||
StandardScoringExperiment experiment = {})
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
archeryActionPointCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache) {}
|
||||
alCache),
|
||||
experiment_(experiment) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
@@ -151,6 +220,8 @@ private:
|
||||
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
StandardScoringExperiment experiment_;
|
||||
};
|
||||
|
||||
// Implementation of StandardAIScoreCalculator methods
|
||||
@@ -159,7 +230,7 @@ auto StandardAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) co
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return INT_MAX;
|
||||
case GameOutcome::ATTACKER_VICTORY: return INT_MIN;
|
||||
case GameOutcome::DRAW: return 0;
|
||||
case GameOutcome::DRAW: return INT_MAX;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
@@ -169,7 +240,7 @@ auto StandardAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) co
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return INT_MAX;
|
||||
case GameOutcome::DEFENDER_VICTORY: return INT_MIN;
|
||||
case GameOutcome::DRAW: return 0;
|
||||
case GameOutcome::DRAW: return INT_MIN;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
@@ -241,10 +312,20 @@ auto StandardAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
const double unitsDifference = components.attackerUnitsValue - components.defenderUnitsValue;
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
|
||||
const double attackerUnitsValue =
|
||||
components.attackerUnitsValue * experiment_.attackerUnitsScale;
|
||||
const double defenderUnitsValue = AdjustDefenderUnitsForExperiment(
|
||||
components.defenderUnitsValue,
|
||||
roundsRemaining,
|
||||
GetMaxRounds(),
|
||||
experiment_);
|
||||
const double unitsDifference = attackerUnitsValue - defenderUnitsValue;
|
||||
// Keep tactical unit value relevant late in the battle. The previous time decay could make
|
||||
// attackers undervalue decisive combat near the round limit.
|
||||
const double unitsMultiplier = experiment_.attackerRoundMultiplier;
|
||||
return UNITS_BASE_MULTIPLIER * experiment_.unitsBaseMultiplierScale * unitsMultiplier *
|
||||
unitsDifference +
|
||||
victoryConditionScore * experiment_.victoryConditionScale;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::GuessedStateScore(
|
||||
@@ -261,10 +342,11 @@ auto StandardAIScoreCalculator::GuessedStateScore(
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeStandardAIScoreCalculator(
|
||||
auto MakeStandardAIScoreCalculatorWithExperiment(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
const ALCache &alCache,
|
||||
const StandardScoringExperiment &experiment) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
@@ -277,13 +359,37 @@ auto MakeStandardAIScoreCalculator(
|
||||
return std::make_unique<StandardAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().archery_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
alCache,
|
||||
experiment);
|
||||
}
|
||||
|
||||
auto MakeStandardAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
return MakeStandardAIScoreCalculatorWithExperiment(
|
||||
settingsGetter,
|
||||
apdCache,
|
||||
alCache,
|
||||
StandardScoringExperiment{});
|
||||
}
|
||||
|
||||
auto MakeExperimentalAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
return MakeStandardAIScoreCalculatorWithExperiment(
|
||||
settingsGetter,
|
||||
apdCache,
|
||||
alCache,
|
||||
ScoringExperimentForId(ExperimentalScoringId()));
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -26,6 +26,12 @@ using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
/// Factory function to create the environment-selected experimental scorer.
|
||||
[[nodiscard]] auto MakeExperimentalAIScoreCalculator(
|
||||
const SettingsGetter& settingsGetter,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_STANDARDAISCORECALCULATOR_HPP
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ constexpr double kMaxProximityBuf = 1.5;
|
||||
constexpr double kDistanceDebufRatio = 8.0;
|
||||
|
||||
/// Structure to hold separated attacker/defender unit scores
|
||||
/// Used by NormalizedAIScoreCalculator to apply asymmetric normalization
|
||||
/// Used by scorers that combine attacker and defender unit values separately.
|
||||
struct UnitsScoreComponents {
|
||||
double attackerUnitsValue;
|
||||
double defenderUnitsValue;
|
||||
|
||||
@@ -157,7 +157,8 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
locationsCausingDanger,
|
||||
notBravingApd,
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost());
|
||||
GetMeteorCastVigorCost(),
|
||||
GetArcheryActionPointCost());
|
||||
|
||||
attackerUnitsValue += distanceMultiplier * uv;
|
||||
}
|
||||
@@ -188,7 +189,8 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
locationsCausingDangerForAttacker,
|
||||
defenderNotBravingApd,
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost());
|
||||
GetMeteorCastVigorCost(),
|
||||
GetArcheryActionPointCost());
|
||||
|
||||
double distanceMultiplier = 1.0;
|
||||
|
||||
@@ -251,7 +253,7 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
cachedHexMap);
|
||||
if (thisDistance < closestDistanceToEnemy) {
|
||||
if (thisDistance < closestDistanceToFriendly) {
|
||||
closestDistanceToFriendly = thisDistance;
|
||||
}
|
||||
}
|
||||
@@ -506,9 +508,7 @@ auto AbstractAIScoreCalculator::AttackerScoreForState(
|
||||
const ScoreValue victoryConditionTotal =
|
||||
CalculateAttackerVictoryConditionScore(gameState, attackerStrategy, castleCoords);
|
||||
|
||||
// Combine scores using subclass-specific logic
|
||||
// Standard: uses difference and multiplier
|
||||
// Normalized: uses normalization formula
|
||||
// Combine scores using subclass-specific logic.
|
||||
return CombineAttackerScores(components, victoryConditionTotal, roundsRemaining);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ protected:
|
||||
AbstractAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
ActionPoints archeryActionPointCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
@@ -53,6 +54,7 @@ protected:
|
||||
const ALCache &alCache)
|
||||
: maxRounds_(maxRounds),
|
||||
braveWaterCost_(braveWaterCost),
|
||||
archeryActionPointCost_(archeryActionPointCost),
|
||||
meteorRange_(meteorRange),
|
||||
meteorCastVigorCost_(meteorCastVigorCost),
|
||||
minimumFleeOddsThreshold_(minimumFleeOddsThreshold),
|
||||
@@ -70,6 +72,9 @@ protected:
|
||||
[[nodiscard]] auto GetApdCache() const -> const APDCache & { return apdCache_; }
|
||||
[[nodiscard]] auto GetAlCache() const -> const ALCache & { return alCache_; }
|
||||
[[nodiscard]] auto GetBraveWaterCost() const -> ActionPoints { return braveWaterCost_; }
|
||||
[[nodiscard]] auto GetArcheryActionPointCost() const -> ActionPoints {
|
||||
return archeryActionPointCost_;
|
||||
}
|
||||
[[nodiscard]] auto GetMaxRounds() const -> int { return maxRounds_; }
|
||||
[[nodiscard]] auto GetMeteorRange() const -> int { return meteorRange_; }
|
||||
[[nodiscard]] auto GetMeteorCastVigorCost() const -> double { return meteorCastVigorCost_; }
|
||||
@@ -146,9 +151,8 @@ protected:
|
||||
-> ScoreValue = 0;
|
||||
|
||||
// Pure virtual method for combining units score and victory condition score
|
||||
// This is where Standard and Normalized diverge in their scoring approach
|
||||
// Standard: uses difference and applies multiplier
|
||||
// Normalized: uses normalization formula with separate attacker/defender values
|
||||
// This is where concrete scorers combine unit value and victory condition pressure.
|
||||
// Standard uses the raw unit-value difference; MCTS-optimized uses bounded scaling.
|
||||
[[nodiscard]] virtual auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
@@ -158,6 +162,7 @@ private:
|
||||
// Scalar settings extracted from SettingsGetter
|
||||
int maxRounds_;
|
||||
ActionPoints braveWaterCost_;
|
||||
ActionPoints archeryActionPointCost_;
|
||||
int meteorRange_;
|
||||
double meteorCastVigorCost_;
|
||||
int minimumFleeOddsThreshold_;
|
||||
|
||||
@@ -54,30 +54,38 @@ std::unique_ptr<BattleConfigProto> AiBattleConfigLoader::CreateDefaultPerfConfig
|
||||
// Basic setup matching Unity's "Perf" configuration
|
||||
config->set_map_name("Alah");
|
||||
config->set_month(month);
|
||||
config->set_max_rounds(40);
|
||||
config->set_random_seed(0); // Use system random
|
||||
config->set_max_rounds(31);
|
||||
config->set_random_seed(1);
|
||||
config->set_use_configured_placements(false);
|
||||
config->set_apply_forced_commands(true);
|
||||
|
||||
// Attacker configuration - 6 Longbowmen units with professions 1-6
|
||||
PlayerConfig* attacker = config->mutable_attacker();
|
||||
attacker->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
attacker->set_scoring_calculator(
|
||||
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType::STANDARD);
|
||||
|
||||
for (int profession = 1; profession <= 6; ++profession) {
|
||||
UnitConfig* unit = attacker->add_units();
|
||||
unit->set_profession(profession);
|
||||
unit->set_battalion_type_id(4); // Longbowmen (battalion type 4)
|
||||
unit->set_starting_position_index(0); // Attackers use position 0
|
||||
unit->set_battalion_type_id(4); // Longbowmen (battalion type 4)
|
||||
unit->set_starting_position_index(0); // Attackers use position 0
|
||||
unit->set_can_flee(true);
|
||||
unit->mutable_battalion()->set_size(1000); // Match client battalion size
|
||||
}
|
||||
|
||||
// Defender configuration - 6 Light Infantry units with NO_PROFESSION (profession 14)
|
||||
PlayerConfig* defender = config->mutable_defender();
|
||||
defender->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
defender->set_scoring_calculator(
|
||||
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType::STANDARD);
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
UnitConfig* unit = defender->add_units();
|
||||
unit->set_profession(14); // NO_PROFESSION to match client battles
|
||||
unit->set_battalion_type_id(0); // Light Infantry (battalion type 0)
|
||||
unit->set_starting_position_index(-1); // Defenders use position -1
|
||||
unit->set_profession(14); // NO_PROFESSION to match client battles
|
||||
unit->set_battalion_type_id(0); // Light Infantry (battalion type 0)
|
||||
unit->set_starting_position_index(-1); // Defenders use position -1
|
||||
unit->set_can_flee(true);
|
||||
unit->mutable_battalion()->set_size(1000); // Match client battalion size
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
|
||||
#include "AiBattleSimulator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/UnitPlacementInfo.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
|
||||
@@ -45,6 +50,67 @@ constexpr int DEFAULT_HERO_INTEGRITY = 0;
|
||||
constexpr int DEFAULT_HERO_AMBITION = 0;
|
||||
constexpr int DEFAULT_HERO_VIGOR = 102;
|
||||
|
||||
constexpr int PROFESSION_MAGE = 1;
|
||||
constexpr double MINIMUM_AGILITY_FOR_ARCHERY = 75.0;
|
||||
constexpr double MINIMUM_AGILITY_FOR_START_FIRE = 70.0;
|
||||
constexpr double MINIMUM_WISDOM_FOR_START_FIRE = 70.0;
|
||||
|
||||
struct SideTotals {
|
||||
int attackerUnits = 0;
|
||||
int defenderUnits = 0;
|
||||
double attackerTroops = 0.0;
|
||||
double defenderTroops = 0.0;
|
||||
};
|
||||
|
||||
std::string JsonEscape(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '"': out << "\\\""; break;
|
||||
case '\\': out << "\\\\"; break;
|
||||
case '\n': out << "\\n"; break;
|
||||
case '\r': out << "\\r"; break;
|
||||
case '\t': out << "\\t"; break;
|
||||
default: out << c; break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string CompletionReasonLabel(EvaluationCompletionReason reason) {
|
||||
switch (reason) {
|
||||
case EvaluationCompletionReason::RAN_OUT_OF_COMMANDS: return "ran_out_of_commands";
|
||||
case EvaluationCompletionReason::RAN_OUT_OF_TIME: return "ran_out_of_time";
|
||||
case EvaluationCompletionReason::NOT_ENOUGH_TIME_TO_CONTINUE:
|
||||
return "not_enough_time_to_continue";
|
||||
}
|
||||
}
|
||||
|
||||
SideTotals CalculateSideTotals(const GameStateW& state) {
|
||||
SideTotals totals;
|
||||
const auto* units = state->units();
|
||||
if (units == nullptr) { return totals; }
|
||||
|
||||
for (const auto* unit : *units) {
|
||||
if (unit == nullptr) { continue; }
|
||||
const bool isAlive =
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT ||
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT ||
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT;
|
||||
if (!isAlive) { continue; }
|
||||
|
||||
if (unit->player_id() == ATTACKER_ID) {
|
||||
totals.attackerUnits++;
|
||||
totals.attackerTroops += unit->battalion().size();
|
||||
} else if (unit->player_id() == DEFENDER_ID) {
|
||||
totals.defenderUnits++;
|
||||
totals.defenderTroops += unit->battalion().size();
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
// Convert proto AI algorithm type to ShardokAI enum
|
||||
AIAlgorithmType ConvertAIAlgorithmType(
|
||||
net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType protoType) {
|
||||
@@ -55,21 +121,160 @@ AIAlgorithmType ConvertAIAlgorithmType(
|
||||
}
|
||||
}
|
||||
|
||||
ScoringCalculatorType ConvertScoringCalculatorType(
|
||||
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType protoType) {
|
||||
switch (protoType) {
|
||||
case net::eagle0::shardok::ai_battle_simulator::EXPERIMENTAL:
|
||||
return ScoringCalculatorType::EXPERIMENTAL;
|
||||
case net::eagle0::shardok::ai_battle_simulator::MCTS_OPTIMIZED:
|
||||
return ScoringCalculatorType::MCTS_OPTIMIZED;
|
||||
case net::eagle0::shardok::ai_battle_simulator::STANDARD:
|
||||
case net::eagle0::shardok::ai_battle_simulator::SCORING_CALCULATOR_TYPE_UNSPECIFIED:
|
||||
default: return ScoringCalculatorType::STANDARD;
|
||||
}
|
||||
}
|
||||
|
||||
mcts::MCTSSimulationPolicy ConvertSimulationPolicy(
|
||||
net::eagle0::shardok::ai_battle_simulator::MctsSimulationPolicy protoType) {
|
||||
switch (protoType) {
|
||||
case net::eagle0::shardok::ai_battle_simulator::RANDOM:
|
||||
return mcts::MCTSSimulationPolicy::RANDOM;
|
||||
case net::eagle0::shardok::ai_battle_simulator::FILTERED_RANDOM:
|
||||
return mcts::MCTSSimulationPolicy::FILTERED_RANDOM;
|
||||
case net::eagle0::shardok::ai_battle_simulator::BEST_IMMEDIATE:
|
||||
return mcts::MCTSSimulationPolicy::BEST_IMMEDIATE;
|
||||
case net::eagle0::shardok::ai_battle_simulator::WEIGHTED_BEST_IMMEDIATE:
|
||||
return mcts::MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE;
|
||||
case net::eagle0::shardok::ai_battle_simulator::WEIGHTED_HEURISTIC:
|
||||
case net::eagle0::shardok::ai_battle_simulator::MCTS_SIMULATION_POLICY_UNSPECIFIED:
|
||||
default: return mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
}
|
||||
}
|
||||
|
||||
mcts::MCTSBackpropagationPolicy ConvertBackpropagationPolicy(
|
||||
net::eagle0::shardok::ai_battle_simulator::MctsBackpropagationPolicy protoType) {
|
||||
switch (protoType) {
|
||||
case net::eagle0::shardok::ai_battle_simulator::AVERAGING:
|
||||
return mcts::MCTSBackpropagationPolicy::AVERAGING;
|
||||
case net::eagle0::shardok::ai_battle_simulator::MINIMAX:
|
||||
case net::eagle0::shardok::ai_battle_simulator::MCTS_BACKPROPAGATION_POLICY_UNSPECIFIED:
|
||||
default: return mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
}
|
||||
}
|
||||
|
||||
mcts::MCTSConfig BuildMCTSConfig(
|
||||
const net::eagle0::shardok::ai_battle_simulator::PlayerConfig& playerConfig,
|
||||
int battleSeed,
|
||||
PlayerId playerId) {
|
||||
mcts::MCTSConfig config{};
|
||||
config.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
config.maxPlayerFlips = 1;
|
||||
config.maxSimulationFlips = 1;
|
||||
config.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
if (battleSeed != 0) { config.useMultithreading = false; }
|
||||
if (battleSeed != 0) {
|
||||
config.randomSeed = static_cast<uint32_t>(battleSeed + (1009 * playerId));
|
||||
}
|
||||
|
||||
if (!playerConfig.has_mcts_config()) { return config; }
|
||||
|
||||
const auto& protoConfig = playerConfig.mcts_config();
|
||||
if (protoConfig.exploration_constant() > 0.0) {
|
||||
config.explorationConstant = protoConfig.exploration_constant();
|
||||
}
|
||||
if (protoConfig.max_simulation_depth() > 0) {
|
||||
config.maxSimulationDepth = protoConfig.max_simulation_depth();
|
||||
}
|
||||
if (protoConfig.max_tree_depth() > 0) { config.maxTreeDepth = protoConfig.max_tree_depth(); }
|
||||
config.useMultithreading = protoConfig.use_multithreading();
|
||||
if (protoConfig.num_threads() > 0) { config.numThreads = protoConfig.num_threads(); }
|
||||
config.simulationPolicy = ConvertSimulationPolicy(protoConfig.simulation_policy());
|
||||
config.backpropagationPolicy =
|
||||
ConvertBackpropagationPolicy(protoConfig.backpropagation_policy());
|
||||
if (protoConfig.max_player_flips() >= 0) {
|
||||
config.maxPlayerFlips = protoConfig.max_player_flips();
|
||||
}
|
||||
if (protoConfig.max_simulation_flips() >= 0) {
|
||||
config.maxSimulationFlips = protoConfig.max_simulation_flips();
|
||||
}
|
||||
if (protoConfig.random_seed() != 0) {
|
||||
config.randomSeed = static_cast<uint32_t>(protoConfig.random_seed());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
bool HasAttachedHero(int profession) { return profession > 0; }
|
||||
|
||||
// Get value with default if not set (proto3 uses 0 as default, we check for that)
|
||||
template<typename T>
|
||||
T GetOrDefault(T value, T defaultValue) {
|
||||
return (value == 0) ? defaultValue : value;
|
||||
}
|
||||
|
||||
bool HeroCanArchery(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
|
||||
if (!HasAttachedHero(unitConfig.profession())) { return false; }
|
||||
return GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY) >=
|
||||
MINIMUM_AGILITY_FOR_ARCHERY;
|
||||
}
|
||||
|
||||
bool HeroCanStartFire(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
|
||||
if (!HasAttachedHero(unitConfig.profession())) { return false; }
|
||||
return unitConfig.profession() == PROFESSION_MAGE ||
|
||||
(GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY) >=
|
||||
MINIMUM_AGILITY_FOR_START_FIRE &&
|
||||
GetOrDefault(unitConfig.hero().wisdom(), DEFAULT_HERO_WISDOM) >=
|
||||
MINIMUM_WISDOM_FOR_START_FIRE);
|
||||
}
|
||||
|
||||
double GetBattalionSizeOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion,
|
||||
double defaultValue) {
|
||||
return battalion.has_size() ? battalion.size() : defaultValue;
|
||||
}
|
||||
|
||||
bool GetCanFleeOrDefault(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
|
||||
return unitConfig.has_can_flee() ? unitConfig.can_flee() : true;
|
||||
}
|
||||
|
||||
double GetBattalionArmamentOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
|
||||
return battalion.has_armament() ? battalion.armament() : DEFAULT_BATTALION_ARMAMENT;
|
||||
}
|
||||
|
||||
double GetBattalionTrainingOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
|
||||
return battalion.has_training() ? battalion.training() : DEFAULT_BATTALION_TRAINING;
|
||||
}
|
||||
|
||||
double GetBattalionMoraleOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
|
||||
return battalion.has_morale() ? battalion.morale() : DEFAULT_BATTALION_MORALE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AiBattleSimulator::AiBattleSimulator(const BattleConfigProto& config, GameSettingsSPtr gameSettings)
|
||||
AiBattleSimulator::AiBattleSimulator(
|
||||
const BattleConfigProto& config,
|
||||
GameSettingsSPtr gameSettings,
|
||||
std::optional<GameStateW> initialGameState,
|
||||
BattleTraceOptions traceOptions)
|
||||
: config_(config),
|
||||
gameSettings_(std::move(gameSettings)) {
|
||||
gameSettings_(std::move(gameSettings)),
|
||||
initialGameState_(std::move(initialGameState)),
|
||||
traceOptions_(std::move(traceOptions)) {
|
||||
if (!gameSettings_) {
|
||||
// Initialize default game settings
|
||||
gameSettings_ = ai_testing_common::GameSettingsFactory::CreateDefault();
|
||||
}
|
||||
if (config_.random_seed() != 0) {
|
||||
auto setter = gameSettings_->GetSetter();
|
||||
setter.SetRandomGenerator(std::make_shared<StdLibraryGenerator>(
|
||||
static_cast<std::mt19937_64::result_type>(config_.random_seed())));
|
||||
}
|
||||
if (config_.max_rounds() > 0) {
|
||||
auto setter = gameSettings_->GetSetter();
|
||||
setter.SetInt("maxRounds", config_.max_rounds());
|
||||
}
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunBattle() {
|
||||
@@ -77,6 +282,7 @@ BattleResult AiBattleSimulator::RunBattle() {
|
||||
std::cout << " Map: " << config_.map_name() << "\n";
|
||||
std::cout << " Month: " << config_.month() << "\n";
|
||||
std::cout << " Max rounds: " << config_.max_rounds() << "\n";
|
||||
std::cout << " Random seed: " << config_.random_seed() << "\n";
|
||||
std::cout << " Attacker AI: "
|
||||
<< net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType_Name(
|
||||
config_.attacker().ai_algorithm())
|
||||
@@ -87,7 +293,7 @@ BattleResult AiBattleSimulator::RunBattle() {
|
||||
<< "\n";
|
||||
|
||||
// Create initial game state
|
||||
auto gameState = CreateInitialGameState();
|
||||
auto gameState = initialGameState_.has_value() ? *initialGameState_ : CreateInitialGameState();
|
||||
|
||||
// Get hex map from game state
|
||||
const auto* hexMap = gameState->hex_map();
|
||||
@@ -99,17 +305,25 @@ BattleResult AiBattleSimulator::RunBattle() {
|
||||
// Create the engine once for the entire simulation
|
||||
ShardokEngine engine(gameSettings_, gameState);
|
||||
|
||||
// Run setup phase
|
||||
std::cout << "\nStarting setup phase...\n";
|
||||
auto setupResult = RunSetupPhase(engine, *attackerAI, *defenderAI);
|
||||
if (setupResult.endReason != BattleResult::EndReason::DRAW) {
|
||||
// Game ended during setup (shouldn't happen normally)
|
||||
return setupResult;
|
||||
int setupCommands = 0;
|
||||
if (!initialGameState_.has_value()) {
|
||||
// Run setup phase
|
||||
std::cout << "\nStarting setup phase...\n";
|
||||
auto setupResult = config_.use_configured_placements()
|
||||
? RunConfiguredSetupPhase(engine)
|
||||
: RunSetupPhase(engine, *attackerAI, *defenderAI);
|
||||
if (setupResult.endReason != BattleResult::EndReason::DRAW) {
|
||||
// Game ended during setup (shouldn't happen normally)
|
||||
return setupResult;
|
||||
}
|
||||
setupCommands = setupResult.totalCommands;
|
||||
} else {
|
||||
std::cout << "\nStarting from saved Shardok state; skipping setup phase.\n";
|
||||
}
|
||||
|
||||
// Run battle phase
|
||||
std::cout << "\nStarting battle phase...\n";
|
||||
return RunBattlePhase(engine, *attackerAI, *defenderAI);
|
||||
return RunBattlePhase(engine, *attackerAI, *defenderAI, setupCommands);
|
||||
}
|
||||
|
||||
GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
@@ -157,7 +371,7 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
unit.mutate_remaining_action_points(12);
|
||||
unit.mutate_hidden(false);
|
||||
unit.mutate_fortified(false);
|
||||
unit.mutate_can_flee(true);
|
||||
unit.mutate_can_flee(GetCanFleeOrDefault(unitConfig));
|
||||
unit.mutate_can_start_fire(false);
|
||||
unit.mutate_can_archery(false);
|
||||
unit.mutate_stun_rounds_remaining(0);
|
||||
@@ -178,18 +392,17 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
// Get battalion type to determine default capacity
|
||||
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
|
||||
const double defaultSize = battalionType->capacity;
|
||||
unit.mutate_can_archery(battalionType->alwaysArcheryCapable || HeroCanArchery(unitConfig));
|
||||
unit.mutate_can_start_fire(HeroCanStartFire(unitConfig));
|
||||
|
||||
battalion.mutate_size(GetOrDefault(unitConfig.battalion().size(), defaultSize));
|
||||
battalion.mutate_armament(
|
||||
GetOrDefault(unitConfig.battalion().armament(), DEFAULT_BATTALION_ARMAMENT));
|
||||
battalion.mutate_training(
|
||||
GetOrDefault(unitConfig.battalion().training(), DEFAULT_BATTALION_TRAINING));
|
||||
battalion.mutate_morale(
|
||||
GetOrDefault(unitConfig.battalion().morale(), DEFAULT_BATTALION_MORALE));
|
||||
battalion.mutate_size(GetBattalionSizeOrDefault(unitConfig.battalion(), defaultSize));
|
||||
battalion.mutate_armament(GetBattalionArmamentOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_training(GetBattalionTrainingOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_morale(GetBattalionMoraleOrDefault(unitConfig.battalion()));
|
||||
unit.mutable_battalion() = battalion;
|
||||
|
||||
// Hero
|
||||
if (unitConfig.profession() > 0) {
|
||||
if (HasAttachedHero(unitConfig.profession())) {
|
||||
unit.mutate_has_attached_hero(true);
|
||||
|
||||
net::eagle0::shardok::storage::fb::Hero hero;
|
||||
@@ -228,6 +441,9 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
} else {
|
||||
unit.mutate_has_attached_hero(false);
|
||||
}
|
||||
if (battalion.size() <= 0 && !unit.has_attached_hero()) {
|
||||
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT);
|
||||
}
|
||||
|
||||
// Initialize opponent knowledge
|
||||
unit.mutable_opponent_knowledge()->Mutate(0, 0);
|
||||
@@ -250,7 +466,7 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
unit.mutate_remaining_action_points(0); // Player 1 starts with 0 AP (not their turn yet)
|
||||
unit.mutate_hidden(false);
|
||||
unit.mutate_fortified(false);
|
||||
unit.mutate_can_flee(true);
|
||||
unit.mutate_can_flee(GetCanFleeOrDefault(unitConfig));
|
||||
unit.mutate_can_start_fire(false);
|
||||
unit.mutate_can_archery(false);
|
||||
unit.mutate_stun_rounds_remaining(0);
|
||||
@@ -271,18 +487,17 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
// Get battalion type to determine default capacity
|
||||
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
|
||||
const double defaultSize = battalionType->capacity;
|
||||
unit.mutate_can_archery(battalionType->alwaysArcheryCapable || HeroCanArchery(unitConfig));
|
||||
unit.mutate_can_start_fire(HeroCanStartFire(unitConfig));
|
||||
|
||||
battalion.mutate_size(GetOrDefault(unitConfig.battalion().size(), defaultSize));
|
||||
battalion.mutate_armament(
|
||||
GetOrDefault(unitConfig.battalion().armament(), DEFAULT_BATTALION_ARMAMENT));
|
||||
battalion.mutate_training(
|
||||
GetOrDefault(unitConfig.battalion().training(), DEFAULT_BATTALION_TRAINING));
|
||||
battalion.mutate_morale(
|
||||
GetOrDefault(unitConfig.battalion().morale(), DEFAULT_BATTALION_MORALE));
|
||||
battalion.mutate_size(GetBattalionSizeOrDefault(unitConfig.battalion(), defaultSize));
|
||||
battalion.mutate_armament(GetBattalionArmamentOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_training(GetBattalionTrainingOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_morale(GetBattalionMoraleOrDefault(unitConfig.battalion()));
|
||||
unit.mutable_battalion() = battalion;
|
||||
|
||||
// Hero
|
||||
if (unitConfig.profession() > 0) {
|
||||
if (HasAttachedHero(unitConfig.profession())) {
|
||||
unit.mutate_has_attached_hero(true);
|
||||
|
||||
net::eagle0::shardok::storage::fb::Hero hero;
|
||||
@@ -321,6 +536,9 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
} else {
|
||||
unit.mutate_has_attached_hero(false);
|
||||
}
|
||||
if (battalion.size() <= 0 && !unit.has_attached_hero()) {
|
||||
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT);
|
||||
}
|
||||
|
||||
// Initialize opponent knowledge
|
||||
unit.mutable_opponent_knowledge()->Mutate(0, 0);
|
||||
@@ -348,13 +566,19 @@ std::unique_ptr<ShardokAIClient> AiBattleSimulator::CreateAIClient(
|
||||
const bool isDefender = (playerId == DEFENDER_ID);
|
||||
|
||||
AIAlgorithmType algorithmType = ConvertAIAlgorithmType(playerConfig.ai_algorithm());
|
||||
const ScoringCalculatorType scoringType =
|
||||
ConvertScoringCalculatorType(playerConfig.scoring_calculator());
|
||||
const auto mctsConfig = BuildMCTSConfig(playerConfig, config_.random_seed(), playerId);
|
||||
|
||||
return ai_testing_common::AIClientFactory::Create(
|
||||
playerId,
|
||||
isDefender,
|
||||
hexMap,
|
||||
gameSettings_->GetGetter(),
|
||||
algorithmType);
|
||||
algorithmType,
|
||||
scoringType,
|
||||
true,
|
||||
mctsConfig);
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunSetupPhase(
|
||||
@@ -365,7 +589,67 @@ BattleResult AiBattleSimulator::RunSetupPhase(
|
||||
return (playerId == ATTACKER_ID) ? attackerAI : defenderAI;
|
||||
};
|
||||
|
||||
auto phaseResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
|
||||
ai_testing_common::PhaseResult phaseResult;
|
||||
|
||||
while (engine.GetCurrentGameState()->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
|
||||
auto currentState = engine.GetCurrentGameState();
|
||||
PlayerId currentPlayer = currentState->current_player();
|
||||
|
||||
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
|
||||
if (availableCommands.empty()) { break; }
|
||||
|
||||
ShardokAIClient& activeAI = getAI(currentPlayer);
|
||||
const SideTotals preTotals = CalculateSideTotals(currentState);
|
||||
auto choiceResults = activeAI.ChooseCommandIndex(engine);
|
||||
const auto command = availableCommands.at(choiceResults.chosenIndex);
|
||||
|
||||
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
|
||||
engine.PostCommand(currentPlayer, choiceResults.chosenIndex, randomGenerator);
|
||||
phaseResult.commandsExecuted++;
|
||||
const auto postState = engine.GetCurrentGameState();
|
||||
const SideTotals postTotals = CalculateSideTotals(postState);
|
||||
|
||||
if (traceOptions_.output != nullptr) {
|
||||
*traceOptions_.output
|
||||
<< "{"
|
||||
<< "\"run_label\":\"" << JsonEscape(traceOptions_.runLabel) << "\","
|
||||
<< "\"config\":\"" << JsonEscape(traceOptions_.configPath) << "\","
|
||||
<< "\"state_file\":\"" << JsonEscape(traceOptions_.statePath) << "\","
|
||||
<< "\"phase\":\"setup\","
|
||||
<< "\"sequence\":" << phaseResult.commandsExecuted << ","
|
||||
<< "\"round\":" << static_cast<int>(currentState->current_round()) << ","
|
||||
<< "\"player\":" << static_cast<int>(currentPlayer) << ","
|
||||
<< "\"side\":\"" << (currentPlayer == ATTACKER_ID ? "attacker" : "defender")
|
||||
<< "\","
|
||||
<< "\"command_type\":\""
|
||||
<< net::eagle0::shardok::common::CommandType_Name(command.type()) << "\","
|
||||
<< "\"actor_unit\":" << (command.has_actor() ? command.actor().value() : -1)
|
||||
<< ","
|
||||
<< "\"target_row\":" << command.target().row() << ","
|
||||
<< "\"target_column\":" << command.target().column() << ","
|
||||
<< "\"chosen_index\":" << choiceResults.chosenIndex << ","
|
||||
<< "\"available_command_count\":" << choiceResults.availableCommandCount << ","
|
||||
<< "\"depth_achieved\":" << choiceResults.depthAchieved << ","
|
||||
<< "\"commands_evaluated\":" << choiceResults.commandCountEvaluated << ","
|
||||
<< "\"completion_reason\":\""
|
||||
<< CompletionReasonLabel(choiceResults.completionReason) << "\","
|
||||
<< "\"forced_commands\":0,"
|
||||
<< "\"pre_attacker_units\":" << preTotals.attackerUnits << ","
|
||||
<< "\"pre_defender_units\":" << preTotals.defenderUnits << ","
|
||||
<< "\"pre_attacker_troops\":" << preTotals.attackerTroops << ","
|
||||
<< "\"pre_defender_troops\":" << preTotals.defenderTroops << ","
|
||||
<< "\"post_attacker_units\":" << postTotals.attackerUnits << ","
|
||||
<< "\"post_defender_units\":" << postTotals.defenderUnits << ","
|
||||
<< "\"post_attacker_troops\":" << postTotals.attackerTroops << ","
|
||||
<< "\"post_defender_troops\":" << postTotals.defenderTroops << "}\n";
|
||||
}
|
||||
|
||||
if (engine.GameIsOver()) {
|
||||
phaseResult.gameEnded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "Setup phase complete. Commands executed: " << phaseResult.commandsExecuted
|
||||
<< "\n";
|
||||
@@ -388,14 +672,72 @@ BattleResult AiBattleSimulator::RunSetupPhase(
|
||||
return result;
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunConfiguredSetupPhase(ShardokEngine& engine) {
|
||||
auto placePlayer = [&](PlayerId playerId,
|
||||
const net::eagle0::shardok::ai_battle_simulator::PlayerConfig&
|
||||
playerConfig,
|
||||
int unitIdOffset) {
|
||||
std::vector<UnitPlacementInfo> placements;
|
||||
placements.reserve(playerConfig.units_size());
|
||||
for (int i = 0; i < playerConfig.units_size(); ++i) {
|
||||
const auto& unitConfig = playerConfig.units(i);
|
||||
if (!unitConfig.has_fixed_position()) {
|
||||
throw std::invalid_argument(
|
||||
"use_configured_placements requires has_fixed_position for every unit");
|
||||
}
|
||||
placements.emplace_back(
|
||||
static_cast<UnitId>(unitIdOffset + i),
|
||||
Coords(unitConfig.row(), unitConfig.column()));
|
||||
}
|
||||
|
||||
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
|
||||
engine.PostPlacementCommands(playerId, placements, randomGenerator);
|
||||
engine.PostFinishedPlacementCommand(playerId, randomGenerator);
|
||||
if (config_.apply_forced_commands()) {
|
||||
engine.PostWhileCurrentPlayerHasOnlyOneOption(randomGenerator);
|
||||
}
|
||||
};
|
||||
|
||||
placePlayer(ATTACKER_ID, config_.attacker(), 0);
|
||||
if (!engine.GameIsOver()) {
|
||||
placePlayer(DEFENDER_ID, config_.defender(), config_.attacker().units_size());
|
||||
}
|
||||
|
||||
std::cout << "Configured setup complete.\n";
|
||||
|
||||
if (engine.GameIsOver()) {
|
||||
return CreateResultFromGameState(
|
||||
engine.GetCurrentGameState(),
|
||||
0,
|
||||
static_cast<int>(engine.GetUnfilteredHistoryCount()));
|
||||
}
|
||||
|
||||
BattleResult result;
|
||||
result.winner = -1;
|
||||
result.totalRounds = 0;
|
||||
result.totalCommands = static_cast<int>(engine.GetUnfilteredHistoryCount());
|
||||
result.endReason = BattleResult::EndReason::DRAW;
|
||||
result.description = "Configured setup completed";
|
||||
return result;
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunBattlePhase(
|
||||
ShardokEngine& engine,
|
||||
ShardokAIClient& attackerAI,
|
||||
ShardokAIClient& defenderAI) {
|
||||
int totalCommands = 0;
|
||||
int currentRound = 1;
|
||||
ShardokAIClient& defenderAI,
|
||||
int setupCommands) {
|
||||
int totalCommands = setupCommands;
|
||||
int attackerCommands = 0;
|
||||
int defenderCommands = 0;
|
||||
int attackerDepthTotal = 0;
|
||||
int defenderDepthTotal = 0;
|
||||
int currentRound = std::max<int>(1, engine.GetCurrentGameState()->current_round());
|
||||
|
||||
while (!engine.GameIsOver() && currentRound <= config_.max_rounds()) {
|
||||
const int harnessMaxRounds = config_.max_rounds() > 0
|
||||
? config_.max_rounds()
|
||||
: gameSettings_->GetGetter().Backing().max_rounds();
|
||||
|
||||
while (!engine.GameIsOver() && currentRound <= harnessMaxRounds) {
|
||||
auto currentState = engine.GetCurrentGameState();
|
||||
PlayerId currentPlayer = currentState->current_player();
|
||||
|
||||
@@ -410,14 +752,66 @@ BattleResult AiBattleSimulator::RunBattlePhase(
|
||||
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
|
||||
|
||||
// Get AI decision
|
||||
const SideTotals preTotals = CalculateSideTotals(currentState);
|
||||
auto choiceResults = activeAI.ChooseCommandIndex(engine);
|
||||
const auto command = availableCommands.at(choiceResults.chosenIndex);
|
||||
|
||||
// Apply command
|
||||
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
|
||||
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
|
||||
engine.PostCommand(currentPlayer, choiceResults.chosenIndex, randomGenerator);
|
||||
totalCommands++;
|
||||
int forcedCommands = 0;
|
||||
if (currentPlayer == ATTACKER_ID) {
|
||||
attackerCommands++;
|
||||
attackerDepthTotal += choiceResults.depthAchieved;
|
||||
} else if (currentPlayer == DEFENDER_ID) {
|
||||
defenderCommands++;
|
||||
defenderDepthTotal += choiceResults.depthAchieved;
|
||||
}
|
||||
|
||||
if (config_.apply_forced_commands()) {
|
||||
forcedCommands = engine.PostWhileCurrentPlayerHasOnlyOneOption(randomGenerator);
|
||||
totalCommands += forcedCommands;
|
||||
}
|
||||
|
||||
// Check round progression
|
||||
auto newState = engine.GetCurrentGameState();
|
||||
const SideTotals postTotals = CalculateSideTotals(newState);
|
||||
if (traceOptions_.output != nullptr) {
|
||||
const int sequence = totalCommands - forcedCommands;
|
||||
*traceOptions_.output
|
||||
<< "{"
|
||||
<< "\"run_label\":\"" << JsonEscape(traceOptions_.runLabel) << "\","
|
||||
<< "\"config\":\"" << JsonEscape(traceOptions_.configPath) << "\","
|
||||
<< "\"state_file\":\"" << JsonEscape(traceOptions_.statePath) << "\","
|
||||
<< "\"phase\":\"battle\","
|
||||
<< "\"sequence\":" << sequence << ","
|
||||
<< "\"round\":" << static_cast<int>(currentState->current_round()) << ","
|
||||
<< "\"player\":" << static_cast<int>(currentPlayer) << ","
|
||||
<< "\"side\":\"" << (currentPlayer == ATTACKER_ID ? "attacker" : "defender")
|
||||
<< "\","
|
||||
<< "\"command_type\":\""
|
||||
<< net::eagle0::shardok::common::CommandType_Name(command.type()) << "\","
|
||||
<< "\"actor_unit\":" << (command.has_actor() ? command.actor().value() : -1)
|
||||
<< ","
|
||||
<< "\"target_row\":" << command.target().row() << ","
|
||||
<< "\"target_column\":" << command.target().column() << ","
|
||||
<< "\"chosen_index\":" << choiceResults.chosenIndex << ","
|
||||
<< "\"available_command_count\":" << choiceResults.availableCommandCount << ","
|
||||
<< "\"depth_achieved\":" << choiceResults.depthAchieved << ","
|
||||
<< "\"commands_evaluated\":" << choiceResults.commandCountEvaluated << ","
|
||||
<< "\"completion_reason\":\""
|
||||
<< CompletionReasonLabel(choiceResults.completionReason) << "\","
|
||||
<< "\"forced_commands\":" << forcedCommands << ","
|
||||
<< "\"pre_attacker_units\":" << preTotals.attackerUnits << ","
|
||||
<< "\"pre_defender_units\":" << preTotals.defenderUnits << ","
|
||||
<< "\"pre_attacker_troops\":" << preTotals.attackerTroops << ","
|
||||
<< "\"pre_defender_troops\":" << preTotals.defenderTroops << ","
|
||||
<< "\"post_attacker_units\":" << postTotals.attackerUnits << ","
|
||||
<< "\"post_defender_units\":" << postTotals.defenderUnits << ","
|
||||
<< "\"post_attacker_troops\":" << postTotals.attackerTroops << ","
|
||||
<< "\"post_defender_troops\":" << postTotals.defenderTroops << "}\n";
|
||||
}
|
||||
if (newState->current_round() > currentRound) {
|
||||
std::cout << "Round " << currentRound
|
||||
<< " completed. Commands this round: " << totalCommands << "\n";
|
||||
@@ -428,7 +822,23 @@ BattleResult AiBattleSimulator::RunBattlePhase(
|
||||
std::cout << "Battle phase complete. Total rounds: " << currentRound
|
||||
<< ", Total commands: " << totalCommands << "\n";
|
||||
|
||||
return CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
|
||||
auto result =
|
||||
CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
|
||||
if (!engine.GameIsOver() && currentRound > harnessMaxRounds) {
|
||||
result.winner = DEFENDER_ID;
|
||||
result.totalRounds = harnessMaxRounds;
|
||||
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
|
||||
result.description = "Defender won by surviving maximum rounds";
|
||||
}
|
||||
result.attackerCommands = attackerCommands;
|
||||
result.defenderCommands = defenderCommands;
|
||||
result.attackerAverageDepth =
|
||||
attackerCommands == 0 ? 0.0
|
||||
: static_cast<double>(attackerDepthTotal) / attackerCommands;
|
||||
result.defenderAverageDepth =
|
||||
defenderCommands == 0 ? 0.0
|
||||
: static_cast<double>(defenderDepthTotal) / defenderCommands;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool AiBattleSimulator::IsGameOver(const GameStateW& state) const {
|
||||
@@ -455,10 +865,10 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
if (winningIds && winningIds->size() > 0) {
|
||||
result.winner = winningIds->Get(0);
|
||||
|
||||
// Determine how the game ended
|
||||
if (result.winner == ATTACKER_ID) {
|
||||
// Attacker won
|
||||
if (totalRounds >= config_.max_rounds()) {
|
||||
if (status->end_game_condition()->victory_details() ==
|
||||
net::eagle0::shardok::storage::fb::
|
||||
VictoryCondition_VICTORY_CONDITION_HOLDS_CRITICAL_TILES) {
|
||||
result.endReason = BattleResult::EndReason::CRITICAL_TILES_CONTROLLED;
|
||||
result.description = "Attacker won by controlling critical tiles";
|
||||
} else {
|
||||
@@ -466,8 +876,9 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
result.description = "Attacker won by eliminating all defenders";
|
||||
}
|
||||
} else if (result.winner == DEFENDER_ID) {
|
||||
// Defender won
|
||||
if (totalRounds >= config_.max_rounds()) {
|
||||
if (status->end_game_condition()->victory_details() ==
|
||||
net::eagle0::shardok::storage::fb::
|
||||
VictoryCondition_VICTORY_CONDITION_WIN_AFTER_MAX_ROUNDS) {
|
||||
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
|
||||
result.description = "Defender won by surviving maximum rounds";
|
||||
} else {
|
||||
@@ -489,10 +900,11 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
result.endReason = BattleResult::EndReason::DRAW;
|
||||
result.description = "Game ended in a draw";
|
||||
} else {
|
||||
// Game not over yet (shouldn't happen)
|
||||
result.winner = -1;
|
||||
result.endReason = BattleResult::EndReason::DRAW;
|
||||
result.description = "Game incomplete";
|
||||
// If the harness stops before the engine posts its max-round status transition, Shardok's
|
||||
// battle rule still awards the battle to the defender.
|
||||
result.winner = DEFENDER_ID;
|
||||
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
|
||||
result.description = "Defender won by surviving maximum rounds";
|
||||
}
|
||||
|
||||
// Collect surviving units (for winner, or all units if draw)
|
||||
@@ -511,6 +923,14 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
|
||||
if (!isAlive) { continue; }
|
||||
|
||||
if (unit->player_id() == ATTACKER_ID) {
|
||||
result.attackerSurvivingUnits++;
|
||||
result.attackerSurvivingTroops += unit->battalion().size();
|
||||
} else if (unit->player_id() == DEFENDER_ID) {
|
||||
result.defenderSurvivingUnits++;
|
||||
result.defenderSurvivingTroops += unit->battalion().size();
|
||||
}
|
||||
|
||||
// For victories, only include winner's units; for draws, include all
|
||||
if (result.winner != -1 && unit->player_id() != result.winner) { continue; }
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#define EAGLE0_AI_BATTLE_SIMULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <ostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -51,6 +53,20 @@ struct BattleResult {
|
||||
// Total number of commands executed
|
||||
int totalCommands;
|
||||
|
||||
// AI-selected commands by player.
|
||||
int attackerCommands = 0;
|
||||
int defenderCommands = 0;
|
||||
|
||||
// Approximate average search depth reported by each AI.
|
||||
double attackerAverageDepth = 0.0;
|
||||
double defenderAverageDepth = 0.0;
|
||||
|
||||
// Surviving units and troop totals by side, useful for scoring A/B tests.
|
||||
int attackerSurvivingUnits = 0;
|
||||
int defenderSurvivingUnits = 0;
|
||||
double attackerSurvivingTroops = 0.0;
|
||||
double defenderSurvivingTroops = 0.0;
|
||||
|
||||
// How the game ended
|
||||
enum class EndReason {
|
||||
LAST_PLAYER_STANDING, // One player eliminated all opponent units
|
||||
@@ -67,6 +83,13 @@ struct BattleResult {
|
||||
std::vector<SurvivingUnit> survivingUnits;
|
||||
};
|
||||
|
||||
struct BattleTraceOptions {
|
||||
std::ostream* output = nullptr;
|
||||
std::string runLabel;
|
||||
std::string configPath;
|
||||
std::string statePath;
|
||||
};
|
||||
|
||||
class AiBattleSimulator {
|
||||
public:
|
||||
/**
|
||||
@@ -77,7 +100,9 @@ public:
|
||||
*/
|
||||
explicit AiBattleSimulator(
|
||||
const BattleConfigProto& config,
|
||||
GameSettingsSPtr gameSettings = nullptr);
|
||||
GameSettingsSPtr gameSettings = nullptr,
|
||||
std::optional<GameStateW> initialGameState = std::nullopt,
|
||||
BattleTraceOptions traceOptions = {});
|
||||
|
||||
/**
|
||||
* Run the battle from start to completion.
|
||||
@@ -90,9 +115,13 @@ public:
|
||||
[[nodiscard]] BattleResult RunBattle();
|
||||
|
||||
private:
|
||||
friend class AiBattleSimulatorTest_GeneratedUnitsMirrorProductionCapabilityFlags_Test;
|
||||
|
||||
// Configuration
|
||||
const BattleConfigProto config_;
|
||||
GameSettingsSPtr gameSettings_;
|
||||
std::optional<GameStateW> initialGameState_;
|
||||
BattleTraceOptions traceOptions_;
|
||||
|
||||
// Game state builder and helpers
|
||||
[[nodiscard]] GameStateW CreateInitialGameState() const;
|
||||
@@ -104,8 +133,13 @@ private:
|
||||
[[nodiscard]] BattleResult
|
||||
RunSetupPhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
|
||||
|
||||
[[nodiscard]] BattleResult
|
||||
RunBattlePhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
|
||||
[[nodiscard]] BattleResult RunConfiguredSetupPhase(ShardokEngine& engine);
|
||||
|
||||
[[nodiscard]] BattleResult RunBattlePhase(
|
||||
ShardokEngine& engine,
|
||||
ShardokAIClient& attackerAI,
|
||||
ShardokAIClient& defenderAI,
|
||||
int setupCommands);
|
||||
|
||||
[[nodiscard]] bool IsGameOver(const GameStateW& state) const;
|
||||
[[nodiscard]] BattleResult
|
||||
|
||||
@@ -21,6 +21,7 @@ cc_library(
|
||||
deps = [
|
||||
":ai_battle_config",
|
||||
"//src/main/cpp/net/eagle0/common:filesystem_utils",
|
||||
"//src/main/cpp/net/eagle0/common:random_generator",
|
||||
"//src/main/cpp/net/eagle0/common:tsv_parser",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
|
||||
@@ -28,6 +29,7 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:unit_placement_info",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
"//src/main/cpp/net/eagle0/shardok/util:battalion_type_registrar",
|
||||
@@ -42,7 +44,7 @@ cc_binary(
|
||||
name = "ai_battle_simulator_main",
|
||||
srcs = ["ai_battle_simulator_main.cpp"],
|
||||
copts = COPTS,
|
||||
data = [
|
||||
data = glob(["benchmarks/*.json"]) + [
|
||||
"//src/main/resources/net/eagle0/shardok:battalion_types",
|
||||
"//src/main/resources/net/eagle0/shardok:settings",
|
||||
"//src/main/resources/net/eagle0/shardok/maps",
|
||||
@@ -54,3 +56,26 @@ cc_binary(
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:fixed_action_point_distances",
|
||||
],
|
||||
)
|
||||
|
||||
cc_binary(
|
||||
name = "real_battle_config_extractor",
|
||||
srcs = ["real_battle_config_extractor_main.cpp"],
|
||||
copts = COPTS + ["-Wno-deprecated-declarations"],
|
||||
deps = [
|
||||
":ai_battle_config",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_binary(
|
||||
name = "real_battle_smoke_metadata",
|
||||
srcs = ["real_battle_smoke_metadata_main.cpp"],
|
||||
copts = COPTS + ["-Wno-deprecated-declarations"],
|
||||
deps = [
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2,37 +2,78 @@
|
||||
// AI Battle Simulator - CLI Entry Point
|
||||
//
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "AiBattleConfig.hpp"
|
||||
#include "AiBattleSimulator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
|
||||
using shardok::GameStateW;
|
||||
using shardok::ai_battle_simulator::AiBattleConfigLoader;
|
||||
using shardok::ai_battle_simulator::AiBattleSimulator;
|
||||
using shardok::ai_battle_simulator::BattleResult;
|
||||
using shardok::ai_battle_simulator::BattleTraceOptions;
|
||||
|
||||
namespace {
|
||||
|
||||
enum class SummaryFormat {
|
||||
HUMAN,
|
||||
CSV,
|
||||
JSON,
|
||||
};
|
||||
|
||||
class NullBuffer : public std::streambuf {
|
||||
protected:
|
||||
int overflow(int c) override { return c; }
|
||||
};
|
||||
|
||||
void PrintUsage(const char* programName) {
|
||||
std::cout << "AI Battle Simulator - AI vs AI Battle Testing Tool\n"
|
||||
<< "\n"
|
||||
<< "Usage:\n"
|
||||
<< " " << programName << " --config=<path> Run battle from JSON config file\n"
|
||||
<< " " << programName << " --config=<path> --config=<path> --summary=csv --quiet\n"
|
||||
<< " " << programName
|
||||
<< " --config=<path> --state-file=<path> --summary=csv --quiet\n"
|
||||
<< " " << programName
|
||||
<< " --config=<path> --trace-jsonl=<path> --run-label=baseline\n"
|
||||
<< " " << programName << " --generate-config Generate sample config to stdout\n"
|
||||
<< " " << programName
|
||||
<< " --generate-config --output=<path> Generate sample config to file\n"
|
||||
<< " " << programName << " --help Show this help message\n"
|
||||
<< "\n"
|
||||
<< "Options:\n"
|
||||
<< " --summary=human|csv|json Output format for completed battle summaries\n"
|
||||
<< " --quiet Suppress simulator progress logs\n"
|
||||
<< " --trace-jsonl=<path> Write one JSON object per AI-selected command\n"
|
||||
<< " --run-label=<label> Label included in trace rows\n"
|
||||
<< "\n"
|
||||
<< "Examples:\n"
|
||||
<< " # Generate sample config\n"
|
||||
<< " " << programName << " --generate-config > my_battle.json\n"
|
||||
<< "\n"
|
||||
<< " # Run battle from config\n"
|
||||
<< " " << programName << " --config=my_battle.json\n"
|
||||
<< "\n"
|
||||
<< " # Run a compact benchmark suite\n"
|
||||
<< " " << programName
|
||||
<< " --config=baseline.json --config=candidate.json --summary=csv --quiet\n"
|
||||
<< "\n"
|
||||
<< " # Run an AI config from a saved Shardok GameState flatbuffer\n"
|
||||
<< " " << programName
|
||||
<< " --config=ai_config.json --state-file=state.fb --summary=csv --quiet\n"
|
||||
<< "\n";
|
||||
}
|
||||
|
||||
@@ -55,23 +96,50 @@ void GenerateConfigFile(const std::string& outputPath) {
|
||||
}
|
||||
}
|
||||
|
||||
void RunBattleFromConfig(const std::string& configPath) {
|
||||
// Load config
|
||||
std::cout << "Loading config from: " << configPath << "\n";
|
||||
auto config = AiBattleConfigLoader::LoadFromJsonFile(configPath);
|
||||
|
||||
if (!config) {
|
||||
std::cerr << "Error: Failed to load config file\n";
|
||||
std::exit(1);
|
||||
std::string CsvEscape(const std::string& value) {
|
||||
bool mustQuote = false;
|
||||
std::string escaped;
|
||||
escaped.reserve(value.size());
|
||||
for (const char c : value) {
|
||||
if (c == '"' || c == ',' || c == '\n' || c == '\r') { mustQuote = true; }
|
||||
if (c == '"') { escaped.push_back('"'); }
|
||||
escaped.push_back(c);
|
||||
}
|
||||
if (!mustQuote) { return escaped; }
|
||||
return "\"" + escaped + "\"";
|
||||
}
|
||||
|
||||
// Create simulator
|
||||
AiBattleSimulator simulator(*config);
|
||||
std::string JsonEscape(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '"': out << "\\\""; break;
|
||||
case '\\': out << "\\\\"; break;
|
||||
case '\n': out << "\\n"; break;
|
||||
case '\r': out << "\\r"; break;
|
||||
case '\t': out << "\\t"; break;
|
||||
default: out << c; break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Run battle
|
||||
auto result = simulator.RunBattle();
|
||||
std::string WinnerLabel(int winner) {
|
||||
if (winner == 0) { return "attacker"; }
|
||||
if (winner == 1) { return "defender"; }
|
||||
return "draw";
|
||||
}
|
||||
|
||||
// Print results
|
||||
std::string EndReasonLabel(BattleResult::EndReason endReason) {
|
||||
switch (endReason) {
|
||||
case BattleResult::EndReason::LAST_PLAYER_STANDING: return "last_player_standing";
|
||||
case BattleResult::EndReason::CRITICAL_TILES_CONTROLLED: return "critical_tiles_controlled";
|
||||
case BattleResult::EndReason::MAX_ROUNDS_REACHED: return "max_rounds_reached";
|
||||
case BattleResult::EndReason::DRAW: return "draw";
|
||||
}
|
||||
}
|
||||
|
||||
void PrintHumanSummary(const BattleResult& result) {
|
||||
std::cout << "\n";
|
||||
std::cout << "=================================\n";
|
||||
std::cout << "Battle Complete\n";
|
||||
@@ -87,10 +155,17 @@ void RunBattleFromConfig(const std::string& configPath) {
|
||||
std::cout << "Result: " << result.description << "\n";
|
||||
std::cout << "Total Rounds: " << result.totalRounds << "\n";
|
||||
std::cout << "Total Commands: " << result.totalCommands << "\n";
|
||||
std::cout << "Attacker AI Commands: " << result.attackerCommands << "\n";
|
||||
std::cout << "Defender AI Commands: " << result.defenderCommands << "\n";
|
||||
std::cout << "Attacker Avg Depth: " << result.attackerAverageDepth << "\n";
|
||||
std::cout << "Defender Avg Depth: " << result.defenderAverageDepth << "\n";
|
||||
std::cout << "Attacker Survivors: " << result.attackerSurvivingUnits << " units, "
|
||||
<< result.attackerSurvivingTroops << " troops\n";
|
||||
std::cout << "Defender Survivors: " << result.defenderSurvivingUnits << " units, "
|
||||
<< result.defenderSurvivingTroops << " troops\n";
|
||||
|
||||
// Print surviving units
|
||||
if (!result.survivingUnits.empty()) {
|
||||
std::cout << "\nSurviving Units (" << result.survivingUnits.size() << "):\n";
|
||||
std::cout << "\nWinning/Draw Surviving Units (" << result.survivingUnits.size() << "):\n";
|
||||
for (const auto& unit : result.survivingUnits) {
|
||||
std::cout << " Unit " << unit.unitId << ": " << unit.battalionTypeName << " ("
|
||||
<< static_cast<int>(unit.battalionSize) << " troops)";
|
||||
@@ -102,6 +177,143 @@ void RunBattleFromConfig(const std::string& configPath) {
|
||||
std::cout << "=================================\n";
|
||||
}
|
||||
|
||||
void PrintCsvHeader() {
|
||||
std::cout << "config,state_file,winner,end_reason,result,total_rounds,total_commands,"
|
||||
<< "attacker_ai_commands,defender_ai_commands,attacker_avg_depth,"
|
||||
<< "defender_avg_depth,attacker_surviving_units,defender_surviving_units,"
|
||||
<< "attacker_surviving_troops,defender_surviving_troops\n";
|
||||
}
|
||||
|
||||
void PrintCsvRow(
|
||||
const std::string& configPath,
|
||||
const std::string& statePath,
|
||||
const BattleResult& result) {
|
||||
std::cout << CsvEscape(configPath) << "," << CsvEscape(statePath) << ","
|
||||
<< WinnerLabel(result.winner) << "," << EndReasonLabel(result.endReason) << ","
|
||||
<< CsvEscape(result.description) << "," << result.totalRounds << ","
|
||||
<< result.totalCommands << "," << result.attackerCommands << ","
|
||||
<< result.defenderCommands << "," << result.attackerAverageDepth << ","
|
||||
<< result.defenderAverageDepth << "," << result.attackerSurvivingUnits << ","
|
||||
<< result.defenderSurvivingUnits << "," << result.attackerSurvivingTroops << ","
|
||||
<< result.defenderSurvivingTroops << "\n";
|
||||
}
|
||||
|
||||
void PrintJsonSummary(
|
||||
const std::string& configPath,
|
||||
const std::string& statePath,
|
||||
const BattleResult& result) {
|
||||
std::cout << "{"
|
||||
<< "\"config\":\"" << JsonEscape(configPath) << "\","
|
||||
<< "\"state_file\":\"" << JsonEscape(statePath) << "\","
|
||||
<< "\"winner\":\"" << WinnerLabel(result.winner) << "\","
|
||||
<< "\"end_reason\":\"" << EndReasonLabel(result.endReason) << "\","
|
||||
<< "\"result\":\"" << JsonEscape(result.description) << "\","
|
||||
<< "\"total_rounds\":" << result.totalRounds << ","
|
||||
<< "\"total_commands\":" << result.totalCommands << ","
|
||||
<< "\"attacker_ai_commands\":" << result.attackerCommands << ","
|
||||
<< "\"defender_ai_commands\":" << result.defenderCommands << ","
|
||||
<< "\"attacker_avg_depth\":" << result.attackerAverageDepth << ","
|
||||
<< "\"defender_avg_depth\":" << result.defenderAverageDepth << ","
|
||||
<< "\"attacker_surviving_units\":" << result.attackerSurvivingUnits << ","
|
||||
<< "\"defender_surviving_units\":" << result.defenderSurvivingUnits << ","
|
||||
<< "\"attacker_surviving_troops\":" << result.attackerSurvivingTroops << ","
|
||||
<< "\"defender_surviving_troops\":" << result.defenderSurvivingTroops << "}\n";
|
||||
}
|
||||
|
||||
std::unique_ptr<shardok::ai_battle_simulator::BattleConfigProto> LoadConfig(
|
||||
const std::string& configPath) {
|
||||
if (configPath.empty()) { return AiBattleConfigLoader::CreateDefaultPerfConfig(); }
|
||||
return AiBattleConfigLoader::LoadFromJsonFile(configPath);
|
||||
}
|
||||
|
||||
GameStateW LoadGameStateFromFile(const std::string& statePath) {
|
||||
if (statePath.empty()) { return GameStateW{}; }
|
||||
auto bytes = FilesystemUtils::LoadFromPath(statePath);
|
||||
if (bytes.empty()) { throw std::runtime_error("Failed to load state file: " + statePath); }
|
||||
return GameStateW::FromByteVector(bytes);
|
||||
}
|
||||
|
||||
BattleResult RunBattle(
|
||||
const std::string& configPath,
|
||||
const std::string& statePath,
|
||||
bool quiet,
|
||||
const std::string& tracePath,
|
||||
const std::string& runLabel) {
|
||||
std::ofstream traceFile;
|
||||
BattleTraceOptions traceOptions;
|
||||
if (!tracePath.empty()) {
|
||||
traceFile.open(tracePath);
|
||||
if (!traceFile.is_open()) {
|
||||
throw std::runtime_error("Failed to open trace file: " + tracePath);
|
||||
}
|
||||
traceOptions.output = &traceFile;
|
||||
traceOptions.runLabel = runLabel;
|
||||
traceOptions.configPath = configPath;
|
||||
traceOptions.statePath = statePath;
|
||||
}
|
||||
|
||||
NullBuffer nullBuffer;
|
||||
std::ostream nullStream(&nullBuffer);
|
||||
auto* originalCout = std::cout.rdbuf();
|
||||
auto* originalCerr = std::cerr.rdbuf();
|
||||
int stdoutCopy = -1;
|
||||
int stderrCopy = -1;
|
||||
if (quiet) {
|
||||
std::cout.rdbuf(nullStream.rdbuf());
|
||||
std::cerr.rdbuf(nullStream.rdbuf());
|
||||
std::fflush(stdout);
|
||||
std::fflush(stderr);
|
||||
stdoutCopy = dup(STDOUT_FILENO);
|
||||
stderrCopy = dup(STDERR_FILENO);
|
||||
const int nullFd = open("/dev/null", O_WRONLY);
|
||||
if (nullFd >= 0) {
|
||||
dup2(nullFd, STDOUT_FILENO);
|
||||
dup2(nullFd, STDERR_FILENO);
|
||||
close(nullFd);
|
||||
}
|
||||
}
|
||||
|
||||
auto restoreOutput = [&]() {
|
||||
if (!quiet) { return; }
|
||||
std::fflush(stdout);
|
||||
std::fflush(stderr);
|
||||
if (stdoutCopy >= 0) {
|
||||
dup2(stdoutCopy, STDOUT_FILENO);
|
||||
close(stdoutCopy);
|
||||
}
|
||||
if (stderrCopy >= 0) {
|
||||
dup2(stderrCopy, STDERR_FILENO);
|
||||
close(stderrCopy);
|
||||
}
|
||||
std::cout.rdbuf(originalCout);
|
||||
std::cerr.rdbuf(originalCerr);
|
||||
};
|
||||
|
||||
// Load config
|
||||
if (!configPath.empty()) { std::cout << "Loading config from: " << configPath << "\n"; }
|
||||
auto config = LoadConfig(configPath);
|
||||
|
||||
if (!config) {
|
||||
restoreOutput();
|
||||
std::cerr << "Error: Failed to load config file\n";
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// Create simulator
|
||||
std::optional<GameStateW> initialGameState;
|
||||
if (!statePath.empty()) {
|
||||
std::cout << "Loading saved state from: " << statePath << "\n";
|
||||
initialGameState = LoadGameStateFromFile(statePath);
|
||||
}
|
||||
AiBattleSimulator simulator(*config, nullptr, std::move(initialGameState), traceOptions);
|
||||
|
||||
// Run battle
|
||||
auto result = simulator.RunBattle();
|
||||
|
||||
restoreOutput();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
@@ -114,9 +326,14 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string configPath;
|
||||
std::vector<std::string> configPaths;
|
||||
std::vector<std::string> statePaths;
|
||||
std::string outputPath;
|
||||
std::string tracePath;
|
||||
std::string runLabel;
|
||||
bool generateConfig = false;
|
||||
bool quiet = false;
|
||||
SummaryFormat summaryFormat = SummaryFormat::HUMAN;
|
||||
|
||||
// Parse command line arguments
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
@@ -128,9 +345,30 @@ int main(int argc, char* argv[]) {
|
||||
} else if (arg == "--generate-config") {
|
||||
generateConfig = true;
|
||||
} else if (arg.starts_with("--config=")) {
|
||||
configPath = arg.substr(9);
|
||||
configPaths.push_back(arg.substr(9));
|
||||
} else if (arg.starts_with("--state-file=")) {
|
||||
statePaths.push_back(arg.substr(13));
|
||||
} else if (arg.starts_with("--output=")) {
|
||||
outputPath = arg.substr(9);
|
||||
} else if (arg.starts_with("--trace-jsonl=")) {
|
||||
tracePath = arg.substr(14);
|
||||
} else if (arg.starts_with("--run-label=")) {
|
||||
runLabel = arg.substr(12);
|
||||
} else if (arg == "--quiet") {
|
||||
quiet = true;
|
||||
} else if (arg.starts_with("--summary=")) {
|
||||
const auto format = arg.substr(10);
|
||||
if (format == "human") {
|
||||
summaryFormat = SummaryFormat::HUMAN;
|
||||
} else if (format == "csv") {
|
||||
summaryFormat = SummaryFormat::CSV;
|
||||
} else if (format == "json") {
|
||||
summaryFormat = SummaryFormat::JSON;
|
||||
} else {
|
||||
std::cerr << "Error: Unknown summary format: " << format << "\n";
|
||||
PrintUsage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
std::cerr << "Error: Unknown argument: " << arg << "\n";
|
||||
PrintUsage(argv[0]);
|
||||
@@ -141,10 +379,25 @@ int main(int argc, char* argv[]) {
|
||||
// Execute appropriate action
|
||||
if (generateConfig) {
|
||||
GenerateConfigFile(outputPath);
|
||||
} else if (!configPath.empty()) {
|
||||
RunBattleFromConfig(configPath);
|
||||
} else if (!configPaths.empty() || !statePaths.empty()) {
|
||||
if (summaryFormat == SummaryFormat::CSV) { PrintCsvHeader(); }
|
||||
if (configPaths.empty()) { configPaths.emplace_back(""); }
|
||||
if (statePaths.empty()) { statePaths.emplace_back(""); }
|
||||
for (const auto& configPath : configPaths) {
|
||||
for (const auto& statePath : statePaths) {
|
||||
const auto result =
|
||||
RunBattle(configPath, statePath, quiet, tracePath, runLabel);
|
||||
switch (summaryFormat) {
|
||||
case SummaryFormat::HUMAN: PrintHumanSummary(result); break;
|
||||
case SummaryFormat::CSV: PrintCsvRow(configPath, statePath, result); break;
|
||||
case SummaryFormat::JSON:
|
||||
PrintJsonSummary(configPath, statePath, result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
std::cerr << "Error: Either --generate-config or --config must be specified\n";
|
||||
std::cerr << "Error: --generate-config, --config, or --state-file must be specified\n";
|
||||
PrintUsage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare paired Shardok AI simulator traces for one or more battles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
STALL_COMMANDS = {
|
||||
"END_TURN_COMMAND",
|
||||
"FLEE_COMMAND",
|
||||
"MOVE_COMMAND",
|
||||
"UNIT_REST_COMMAND",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceEvent:
|
||||
run_label: str
|
||||
phase: str
|
||||
sequence: int
|
||||
round: int
|
||||
side: str
|
||||
player: int
|
||||
command_type: str
|
||||
actor_unit: int
|
||||
available_command_count: int
|
||||
depth_achieved: int
|
||||
completion_reason: str
|
||||
forced_commands: int
|
||||
pre_attacker_troops: int
|
||||
pre_defender_troops: int
|
||||
post_attacker_troops: int
|
||||
post_defender_troops: int
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, row: dict[str, object]) -> TraceEvent:
|
||||
return cls(
|
||||
run_label=str(row["run_label"]),
|
||||
phase=str(row["phase"]),
|
||||
sequence=int(row["sequence"]),
|
||||
round=int(row["round"]),
|
||||
side=str(row["side"]),
|
||||
player=int(row["player"]),
|
||||
command_type=str(row["command_type"]),
|
||||
actor_unit=int(row["actor_unit"]),
|
||||
available_command_count=int(row["available_command_count"]),
|
||||
depth_achieved=int(row["depth_achieved"]),
|
||||
completion_reason=str(row["completion_reason"]),
|
||||
forced_commands=int(row["forced_commands"]),
|
||||
pre_attacker_troops=int(row["pre_attacker_troops"]),
|
||||
pre_defender_troops=int(row["pre_defender_troops"]),
|
||||
post_attacker_troops=int(row["post_attacker_troops"]),
|
||||
post_defender_troops=int(row["post_defender_troops"]),
|
||||
)
|
||||
|
||||
@property
|
||||
def attacker_delta(self) -> int:
|
||||
return self.post_attacker_troops - self.pre_attacker_troops
|
||||
|
||||
@property
|
||||
def defender_delta(self) -> int:
|
||||
return self.post_defender_troops - self.pre_defender_troops
|
||||
|
||||
@property
|
||||
def did_damage(self) -> bool:
|
||||
return self.attacker_delta < 0 or self.defender_delta < 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoundSummary:
|
||||
battle_round: int
|
||||
attacker_commands: int
|
||||
defender_commands: int
|
||||
attacker_damage: int
|
||||
defender_damage: int
|
||||
attacker_command_counts: Counter[str]
|
||||
defender_command_counts: Counter[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunSummary:
|
||||
label: str
|
||||
final_attacker_troops: int
|
||||
final_defender_troops: int
|
||||
command_counts: Counter[str]
|
||||
side_command_counts: dict[str, Counter[str]]
|
||||
side_damage_done: dict[str, int]
|
||||
no_damage_streaks: list[list[TraceEvent]]
|
||||
rounds: dict[int, RoundSummary]
|
||||
|
||||
|
||||
def load_trace(path: Path) -> list[TraceEvent]:
|
||||
events = []
|
||||
with path.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
events.append(TraceEvent.from_json(json.loads(line)))
|
||||
return events
|
||||
|
||||
|
||||
def battle_events(events: Iterable[TraceEvent]) -> list[TraceEvent]:
|
||||
return [event for event in events if event.phase == "battle"]
|
||||
|
||||
|
||||
def side_damage(event: TraceEvent) -> int:
|
||||
if event.side == "attacker":
|
||||
return max(0, -event.defender_delta)
|
||||
return max(0, -event.attacker_delta)
|
||||
|
||||
|
||||
def longest_no_damage_streaks(events: list[TraceEvent], limit: int) -> list[list[TraceEvent]]:
|
||||
streaks = []
|
||||
current = []
|
||||
for event in events:
|
||||
if event.command_type in STALL_COMMANDS and not event.did_damage:
|
||||
current.append(event)
|
||||
continue
|
||||
if current:
|
||||
streaks.append(current)
|
||||
current = []
|
||||
if current:
|
||||
streaks.append(current)
|
||||
streaks.sort(key=len, reverse=True)
|
||||
return streaks[:limit]
|
||||
|
||||
|
||||
def summarize_run(label: str, events: list[TraceEvent], streak_limit: int) -> RunSummary:
|
||||
battle = battle_events(events)
|
||||
if not battle:
|
||||
raise RuntimeError(f"No battle events in {label}")
|
||||
|
||||
command_counts = Counter(event.command_type for event in battle)
|
||||
side_command_counts: dict[str, Counter[str]] = {
|
||||
"attacker": Counter(),
|
||||
"defender": Counter(),
|
||||
}
|
||||
side_damage_done = {
|
||||
"attacker": 0,
|
||||
"defender": 0,
|
||||
}
|
||||
per_round: dict[int, list[TraceEvent]] = defaultdict(list)
|
||||
for event in battle:
|
||||
side_command_counts[event.side][event.command_type] += 1
|
||||
side_damage_done[event.side] += side_damage(event)
|
||||
per_round[event.round].append(event)
|
||||
|
||||
rounds = {}
|
||||
for battle_round, round_events in sorted(per_round.items()):
|
||||
attacker_events = [event for event in round_events if event.side == "attacker"]
|
||||
defender_events = [event for event in round_events if event.side == "defender"]
|
||||
rounds[battle_round] = RoundSummary(
|
||||
battle_round=battle_round,
|
||||
attacker_commands=len(attacker_events),
|
||||
defender_commands=len(defender_events),
|
||||
attacker_damage=sum(side_damage(event) for event in attacker_events),
|
||||
defender_damage=sum(side_damage(event) for event in defender_events),
|
||||
attacker_command_counts=Counter(event.command_type for event in attacker_events),
|
||||
defender_command_counts=Counter(event.command_type for event in defender_events),
|
||||
)
|
||||
|
||||
final = battle[-1]
|
||||
return RunSummary(
|
||||
label=label,
|
||||
final_attacker_troops=final.post_attacker_troops,
|
||||
final_defender_troops=final.post_defender_troops,
|
||||
command_counts=command_counts,
|
||||
side_command_counts=side_command_counts,
|
||||
side_damage_done=side_damage_done,
|
||||
no_damage_streaks=longest_no_damage_streaks(battle, streak_limit),
|
||||
rounds=rounds,
|
||||
)
|
||||
|
||||
|
||||
def command_counts_text(counts: Counter[str]) -> str:
|
||||
if not counts:
|
||||
return ""
|
||||
return ", ".join(f"{name.removesuffix('_COMMAND')} {count}" for name, count in counts.most_common())
|
||||
|
||||
|
||||
def format_streak(streak: list[TraceEvent]) -> str:
|
||||
first = streak[0]
|
||||
last = streak[-1]
|
||||
command_counts = Counter(event.command_type for event in streak)
|
||||
return (
|
||||
f"rounds {first.round}-{last.round}, seq {first.sequence}-{last.sequence}, "
|
||||
f"{len(streak)} commands: {command_counts_text(command_counts)}"
|
||||
)
|
||||
|
||||
|
||||
def write_battle_report(
|
||||
lines: list[str],
|
||||
battle: int,
|
||||
baseline: RunSummary,
|
||||
candidate: RunSummary,
|
||||
) -> None:
|
||||
lines.extend(
|
||||
[
|
||||
f"## Battle {battle}",
|
||||
"",
|
||||
"| Run | Final troops | Attacker damage | Defender damage | Attacker commands | Defender commands |",
|
||||
"| --- | ---: | ---: | ---: | --- | --- |",
|
||||
]
|
||||
)
|
||||
for summary in [baseline, candidate]:
|
||||
lines.append(
|
||||
f"| {summary.label} | {summary.final_attacker_troops} vs {summary.final_defender_troops} | "
|
||||
f"{summary.side_damage_done['attacker']} | {summary.side_damage_done['defender']} | "
|
||||
f"{command_counts_text(summary.side_command_counts['attacker'])} | "
|
||||
f"{command_counts_text(summary.side_command_counts['defender'])} |"
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### Per-Round Differences",
|
||||
"",
|
||||
"| Round | Baseline attacker | Candidate attacker | Baseline defender | Candidate defender |",
|
||||
"| ---: | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
all_rounds = sorted(set(baseline.rounds) | set(candidate.rounds))
|
||||
for battle_round in all_rounds:
|
||||
baseline_round = baseline.rounds.get(battle_round)
|
||||
candidate_round = candidate.rounds.get(battle_round)
|
||||
baseline_attacker = round_side_text(baseline_round, "attacker")
|
||||
candidate_attacker = round_side_text(candidate_round, "attacker")
|
||||
baseline_defender = round_side_text(baseline_round, "defender")
|
||||
candidate_defender = round_side_text(candidate_round, "defender")
|
||||
if baseline_attacker == candidate_attacker and baseline_defender == candidate_defender:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {battle_round} | {baseline_attacker} | {candidate_attacker} | "
|
||||
f"{baseline_defender} | {candidate_defender} |"
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### Longest No-Damage Streaks",
|
||||
"",
|
||||
"| Run | Streak |",
|
||||
"| --- | --- |",
|
||||
]
|
||||
)
|
||||
for summary in [baseline, candidate]:
|
||||
if not summary.no_damage_streaks:
|
||||
lines.append(f"| {summary.label} | _none_ |")
|
||||
continue
|
||||
for streak in summary.no_damage_streaks:
|
||||
lines.append(f"| {summary.label} | {format_streak(streak)} |")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def round_side_text(summary: RoundSummary | None, side: str) -> str:
|
||||
if summary is None:
|
||||
return "_none_"
|
||||
if side == "attacker":
|
||||
damage = summary.attacker_damage
|
||||
counts = summary.attacker_command_counts
|
||||
commands = summary.attacker_commands
|
||||
else:
|
||||
damage = summary.defender_damage
|
||||
counts = summary.defender_command_counts
|
||||
commands = summary.defender_commands
|
||||
return f"{commands} cmds, {damage} dmg ({command_counts_text(counts)})"
|
||||
|
||||
|
||||
def parse_battles(value: str) -> list[int]:
|
||||
return [int(item.strip()) for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--experiment-dir", type=Path, required=True)
|
||||
parser.add_argument("--battles", required=True, help="Comma-separated action_seq values")
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--streak-limit", type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
|
||||
lines = [
|
||||
"# Trace Comparison",
|
||||
"",
|
||||
f"- Experiment directory: `{args.experiment_dir}`",
|
||||
f"- Battles: `{args.battles}`",
|
||||
"",
|
||||
]
|
||||
for battle in parse_battles(args.battles):
|
||||
trace_dir = args.experiment_dir / "traces"
|
||||
baseline_events = load_trace(trace_dir / f"{battle}_baseline.jsonl")
|
||||
candidate_events = load_trace(trace_dir / f"{battle}_candidate.jsonl")
|
||||
baseline = summarize_run("baseline", baseline_events, args.streak_limit)
|
||||
candidate = summarize_run("candidate", candidate_events, args.streak_limit)
|
||||
write_battle_report(lines, battle, baseline, candidate)
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text("\n".join(lines))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
action_seq,shardok_game_id,attacker_factions,defender_factions,real_winner,sim_winner,winner_match,real_round,sim_round,real_attacker_troops,real_defender_troops,sim_attacker_troops,sim_defender_troops
|
||||
306,389ffb1901903118_1_b3e7d8ab,1,2,attacker,attacker,true,0,19,1454,0,1388,0
|
||||
307,389ffb1901903118_2_bf499d3c,2,1,defender,defender,true,0,16,0,699,0,648
|
||||
399,389ffb1901903118_3_cd9141a,2,1,defender,defender,true,0,6,0,1310,0,1315
|
||||
631,389ffb1901903118_4_6e7b9b,1,2,defender,defender,true,0,5,0,3766,0,3654
|
||||
1326,389ffb1901903118_5_8a57255a,9,6,attacker,attacker,true,0,11,1715,0,1701,0
|
||||
2437,389ffb1901903118_7_5cf74e0,2,1,attacker,attacker,true,0,16,3146,0,3247,0
|
||||
2584,389ffb1901903118_8_41ce0ebd,1,2,defender,defender,true,0,31,0,2028,0,168
|
||||
2654,389ffb1901903118_9_eb43d421,2,1,defender,defender,true,0,28,0,335,0,467
|
||||
3536,389ffb1901903118_a_950de1c1,1,2,attacker,attacker,true,0,12,2172,0,2528,0
|
||||
3701,389ffb1901903118_b_5d1d76e0,8,2,defender,defender,true,0,30,0,2819,0,1348
|
||||
4298,389ffb1901903118_c_7bdb54dd,2,1,defender,defender,true,0,3,0,3514,0,1977
|
||||
4576,389ffb1901903118_d_689ee2fa,2,1,defender,defender,true,0,4,0,2028,0,1986
|
||||
6014,389ffb1901903118_e_89c90dc6,2,8,defender,defender,true,0,6,0,1942,0,2252
|
||||
6248,389ffb1901903118_f_3a9d9136,7,9,attacker,attacker,true,0,25,520,0,676,0
|
||||
6705,389ffb1901903118_11_e0611233,1,2,defender,defender,true,0,31,0,3316,0,535
|
||||
7212,389ffb1901903118_12_84a36154,2,8,defender,defender,true,0,8,0,1253,0,1273
|
||||
8217,389ffb1901903118_13_2e98b1cf,8,2,attacker,attacker,true,0,22,920,0,2167,0
|
||||
8394,389ffb1901903118_14_b5570863,2,8,attacker,attacker,true,0,9,2449,0,3186,0
|
||||
9975,389ffb1901903118_16_3d6ec341,2,1,defender,defender,true,0,4,0,7420,0,7840
|
||||
10229,389ffb1901903118_17_3ba689e3,5,7,attacker,attacker,true,0,23,2135,0,1816,0
|
||||
10631,389ffb1901903118_18_9e463db3,1,2,attacker,attacker,true,0,1,4000,0,4000,0
|
||||
11011,389ffb1901903118_19_239725d3,2,1,defender,defender,true,0,5,0,3532,0,3658
|
||||
11012,389ffb1901903118_1a_16e1ebf,9,6,defender,defender,true,0,30,0,846,0,1347
|
||||
11297,389ffb1901903118_1c_529ba6e5,1,2,attacker,attacker,true,0,1,2400,0,2400,0
|
||||
11469,389ffb1901903118_1d_49993012,5,9,defender,defender,true,0,8,0,787,0,1489
|
||||
11470,389ffb1901903118_1e_fd8c60a8,7,5,attacker,attacker,true,0,17,3220,0,3425,0
|
||||
11565,389ffb1901903118_1f_53309484,5,7,defender,defender,true,0,31,190,3420,78,3600
|
||||
12136,389ffb1901903118_24_f0d61a5a,9,7,attacker,attacker,true,0,21,1829,0,1581,0
|
||||
12137,389ffb1901903118_25_a19c4d87,2,1,defender,defender,true,0,8,0,1894,0,1892
|
||||
12765,389ffb1901903118_27_c008989b,6,9,defender,defender,true,0,11,0,1773,0,1763
|
||||
13752,389ffb1901903118_28_b33e2868,2,1,defender,defender,true,0,7,0,943,0,932
|
||||
|
+45
@@ -0,0 +1,45 @@
|
||||
# AI-vs-AI Real Battle Smoke Test
|
||||
|
||||
This smoke test reruns saved battles where neither side used faction `3` or `4`,
|
||||
then compares STANDARD-vs-STANDARD winner side with the winner side recorded by
|
||||
the original Shardok battle. It is intentionally a broad similarity check rather
|
||||
than an exact replay assertion.
|
||||
|
||||
- Checked battles: 31
|
||||
- Winner-side matches: 31
|
||||
- Match rate: 100%
|
||||
- Required match rate: 75%
|
||||
|
||||
| Action seq | Factions | Real winner | Sim winner | Real troops | Sim troops |
|
||||
| ---: | --- | --- | --- | ---: | ---: |
|
||||
| 306 | 1 vs 2 | attacker | attacker | 1454 vs 0 | 1388 vs 0 |
|
||||
| 307 | 2 vs 1 | defender | defender | 0 vs 699 | 0 vs 648 |
|
||||
| 399 | 2 vs 1 | defender | defender | 0 vs 1310 | 0 vs 1315 |
|
||||
| 631 | 1 vs 2 | defender | defender | 0 vs 3766 | 0 vs 3654 |
|
||||
| 1326 | 9 vs 6 | attacker | attacker | 1715 vs 0 | 1701 vs 0 |
|
||||
| 2437 | 2 vs 1 | attacker | attacker | 3146 vs 0 | 3247 vs 0 |
|
||||
| 2584 | 1 vs 2 | defender | defender | 0 vs 2028 | 0 vs 168 |
|
||||
| 2654 | 2 vs 1 | defender | defender | 0 vs 335 | 0 vs 467 |
|
||||
| 3536 | 1 vs 2 | attacker | attacker | 2172 vs 0 | 2528 vs 0 |
|
||||
| 3701 | 8 vs 2 | defender | defender | 0 vs 2819 | 0 vs 1348 |
|
||||
| 4298 | 2 vs 1 | defender | defender | 0 vs 3514 | 0 vs 1977 |
|
||||
| 4576 | 2 vs 1 | defender | defender | 0 vs 2028 | 0 vs 1986 |
|
||||
| 6014 | 2 vs 8 | defender | defender | 0 vs 1942 | 0 vs 2252 |
|
||||
| 6248 | 7 vs 9 | attacker | attacker | 520 vs 0 | 676 vs 0 |
|
||||
| 6705 | 1 vs 2 | defender | defender | 0 vs 3316 | 0 vs 535 |
|
||||
| 7212 | 2 vs 8 | defender | defender | 0 vs 1253 | 0 vs 1273 |
|
||||
| 8217 | 8 vs 2 | attacker | attacker | 920 vs 0 | 2167 vs 0 |
|
||||
| 8394 | 2 vs 8 | attacker | attacker | 2449 vs 0 | 3186 vs 0 |
|
||||
| 9975 | 2 vs 1 | defender | defender | 0 vs 7420 | 0 vs 7840 |
|
||||
| 10229 | 5 vs 7 | attacker | attacker | 2135 vs 0 | 1816 vs 0 |
|
||||
| 10631 | 1 vs 2 | attacker | attacker | 4000 vs 0 | 4000 vs 0 |
|
||||
| 11011 | 2 vs 1 | defender | defender | 0 vs 3532 | 0 vs 3658 |
|
||||
| 11012 | 9 vs 6 | defender | defender | 0 vs 846 | 0 vs 1347 |
|
||||
| 11297 | 1 vs 2 | attacker | attacker | 2400 vs 0 | 2400 vs 0 |
|
||||
| 11469 | 5 vs 9 | defender | defender | 0 vs 787 | 0 vs 1489 |
|
||||
| 11470 | 7 vs 5 | attacker | attacker | 3220 vs 0 | 3425 vs 0 |
|
||||
| 11565 | 5 vs 7 | defender | defender | 190 vs 3420 | 78 vs 3600 |
|
||||
| 12136 | 9 vs 7 | attacker | attacker | 1829 vs 0 | 1581 vs 0 |
|
||||
| 12137 | 2 vs 1 | defender | defender | 0 vs 1894 | 0 vs 1892 |
|
||||
| 12765 | 6 vs 9 | defender | defender | 0 vs 1773 | 0 vs 1763 |
|
||||
| 13752 | 2 vs 1 | defender | defender | 0 vs 943 | 0 vs 932 |
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
variant,action_seq,config,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
expected_impact_archery_standard_baseline,306,/private/tmp/eagle0-standard-baselines/configs/306_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,19,99,41,32,4.04878,3.65625,2,0,1388,0,
|
||||
expected_impact_archery_standard_baseline,307,/private/tmp/eagle0-standard-baselines/configs/307_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,16,75,37,15,3.54054,2.86667,0,1,0,648,
|
||||
expected_impact_archery_standard_baseline,399,/private/tmp/eagle0-standard-baselines/configs/399_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,6,30,7,14,3,3.85714,0,3,0,1315,
|
||||
expected_impact_archery_standard_baseline,631,/private/tmp/eagle0-standard-baselines/configs/631_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,5,38,12,14,3.58333,4.14286,0,5,0,3654,
|
||||
expected_impact_archery_standard_baseline,1326,/private/tmp/eagle0-standard-baselines/configs/1326_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,11,72,34,19,3.26471,3.63158,2,0,1701,0,
|
||||
expected_impact_archery_standard_baseline,1472,/private/tmp/eagle0-standard-baselines/configs/1472_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,143,59,67,3.25424,3.46269,0,2,0,944,
|
||||
expected_impact_archery_standard_baseline,2437,/private/tmp/eagle0-standard-baselines/configs/2437_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,16,148,89,43,3.37079,3.32558,5,0,3247,0,
|
||||
expected_impact_archery_standard_baseline,2584,/private/tmp/eagle0-standard-baselines/configs/2584_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,190,48,85,3.45833,3.16471,0,1,0,168,
|
||||
expected_impact_archery_standard_baseline,2654,/private/tmp/eagle0-standard-baselines/configs/2654_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,28,198,78,72,3.19231,4.04167,0,2,0,467,
|
||||
expected_impact_archery_standard_baseline,3536,/private/tmp/eagle0-standard-baselines/configs/3536_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,12,120,57,36,3.40351,2.88889,4,0,2528,0,
|
||||
expected_impact_archery_standard_baseline,3701,/private/tmp/eagle0-standard-baselines/configs/3701_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,155,62,70,3.5,3.24286,0,4,0,1348,
|
||||
expected_impact_archery_standard_baseline,4298,/private/tmp/eagle0-standard-baselines/configs/4298_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,3,38,13,11,3.07692,2.90909,0,6,0,1977,
|
||||
expected_impact_archery_standard_baseline,4576,/private/tmp/eagle0-standard-baselines/configs/4576_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,4,35,17,7,3,3.28571,0,3,0,1986,
|
||||
expected_impact_archery_standard_baseline,6014,/private/tmp/eagle0-standard-baselines/configs/6014_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,6,39,20,7,3.15,3.42857,0,3,0,2252,
|
||||
expected_impact_archery_standard_baseline,6248,/private/tmp/eagle0-standard-baselines/configs/6248_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,25,153,75,46,3.65333,2.8913,2,0,676,0,
|
||||
expected_impact_archery_standard_baseline,6408,/private/tmp/eagle0-standard-baselines/configs/6408_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,11,107,68,18,3.5,3.33333,5,0,4020,0,
|
||||
expected_impact_archery_standard_baseline,6705,/private/tmp/eagle0-standard-baselines/configs/6705_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,205,87,86,3.01149,3.74419,0,2,0,535,
|
||||
expected_impact_archery_standard_baseline,7212,/private/tmp/eagle0-standard-baselines/configs/7212_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,39,18,11,3.38889,3.63636,0,2,0,1273,
|
||||
expected_impact_archery_standard_baseline,8217,/private/tmp/eagle0-standard-baselines/configs/8217_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,22,164,79,49,3.35443,3.08163,3,0,2167,0,
|
||||
expected_impact_archery_standard_baseline,8394,/private/tmp/eagle0-standard-baselines/configs/8394_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,9,81,48,17,3.25,2.64706,4,0,3186,0,
|
||||
expected_impact_archery_standard_baseline,8628,/private/tmp/eagle0-standard-baselines/configs/8628_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,5,41,27,3,2.96296,2,4,0,2760,0,
|
||||
expected_impact_archery_standard_baseline,9975,/private/tmp/eagle0-standard-baselines/configs/9975_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,4,70,23,27,2.65217,2.40741,0,10,0,7840,
|
||||
expected_impact_archery_standard_baseline,10229,/private/tmp/eagle0-standard-baselines/configs/10229_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,23,104,57,4,3.77193,3,3,0,1816,0,
|
||||
expected_impact_archery_standard_baseline,10631,/private/tmp/eagle0-standard-baselines/configs/10631_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,1,16,7,1,3,3,5,1,4000,0,
|
||||
expected_impact_archery_standard_baseline,11011,/private/tmp/eagle0-standard-baselines/configs/11011_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,5,51,22,18,3.5,3,0,5,0,3658,
|
||||
expected_impact_archery_standard_baseline,11012,/private/tmp/eagle0-standard-baselines/configs/11012_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,146,47,67,3.40426,3.62687,0,3,0,1347,
|
||||
expected_impact_archery_standard_baseline,11112,/private/tmp/eagle0-standard-baselines/configs/11112_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,3,31,7,8,3.42857,2,0,11,0,7595,
|
||||
expected_impact_archery_standard_baseline,11297,/private/tmp/eagle0-standard-baselines/configs/11297_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,1,13,5,1,3,3,3,1,2400,0,
|
||||
expected_impact_archery_standard_baseline,11469,/private/tmp/eagle0-standard-baselines/configs/11469_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,58,30,12,3.16667,3.16667,0,2,0,1489,
|
||||
expected_impact_archery_standard_baseline,11470,/private/tmp/eagle0-standard-baselines/configs/11470_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,17,99,65,22,3.50769,2.68182,4,0,3425,0,
|
||||
expected_impact_archery_standard_baseline,11565,/private/tmp/eagle0-standard-baselines/configs/11565_standard.json,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,103,30,34,4.26667,3.91176,1,4,78,3600,
|
||||
expected_impact_archery_standard_baseline,11662,/private/tmp/eagle0-standard-baselines/configs/11662_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,8,120,85,17,2.85882,3.23529,9,0,6624,0,
|
||||
expected_impact_archery_standard_baseline,11754,/private/tmp/eagle0-standard-baselines/configs/11754_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,163,72,76,3.31944,3.28947,0,3,0,1000,
|
||||
expected_impact_archery_standard_baseline,11941,/private/tmp/eagle0-standard-baselines/configs/11941_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,6,64,44,7,3.25,3,6,0,4777,0,
|
||||
expected_impact_archery_standard_baseline,11942,/private/tmp/eagle0-standard-baselines/configs/11942_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,5,91,59,16,2.64407,3.375,12,0,8572,0,
|
||||
expected_impact_archery_standard_baseline,12136,/private/tmp/eagle0-standard-baselines/configs/12136_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,21,141,79,34,3.70886,4.52941,3,0,1581,0,
|
||||
expected_impact_archery_standard_baseline,12137,/private/tmp/eagle0-standard-baselines/configs/12137_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,50,19,17,3.21053,3.29412,0,3,0,1892,
|
||||
expected_impact_archery_standard_baseline,12138,/private/tmp/eagle0-standard-baselines/configs/12138_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,14,102,69,16,3.43478,2.9375,3,0,2512,0,
|
||||
expected_impact_archery_standard_baseline,12765,/private/tmp/eagle0-standard-baselines/configs/12765_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,11,46,26,11,3.61538,3.09091,0,2,0,1763,
|
||||
expected_impact_archery_standard_baseline,13752,/private/tmp/eagle0-standard-baselines/configs/13752_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,7,38,16,10,3.5,3.3,0,2,0,932,
|
||||
|
+40
@@ -0,0 +1,40 @@
|
||||
# STANDARD Scoring All-40 Baseline
|
||||
|
||||
This baseline is the current comparison baseline for STANDARD scoring experiments. It was
|
||||
regenerated after fixing iterative-deepening timeout score propagation.
|
||||
Generated battle configs preserve archery/start-fire capability, per-unit flee capability,
|
||||
explicit zero-size battalions, no-profession heroes, forced commands, seed `1`, and the
|
||||
31-round defender timeout.
|
||||
|
||||
Raw rows are recorded in `standard_scoring_all40_post_bugfix_baseline.csv`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
| --- | ---: |
|
||||
| Real battles | 40 |
|
||||
| Valid simulated battles | 40 |
|
||||
| Invalid / no recoverable troops | 0 |
|
||||
| Attacker wins | 18 |
|
||||
| Defender wins | 22 |
|
||||
| Last-player-standing endings | 39 |
|
||||
| Max-round defender endings | 1 |
|
||||
|
||||
## Closest Battles By Survivor Margin
|
||||
|
||||
Survivor margin is `attacker_surviving_troops - defender_surviving_troops`.
|
||||
|
||||
| Action seq | Winner | End reason | Attacker troops | Defender troops | Margin |
|
||||
| ---: | --- | --- | ---: | ---: | ---: |
|
||||
| 2584 | defender | last_player_standing | 0 | 168 | -168 |
|
||||
| 2654 | defender | last_player_standing | 0 | 467 | -467 |
|
||||
| 6705 | defender | last_player_standing | 0 | 535 | -535 |
|
||||
| 307 | defender | last_player_standing | 0 | 648 | -648 |
|
||||
| 6248 | attacker | last_player_standing | 676 | 0 | +676 |
|
||||
| 13752 | defender | last_player_standing | 0 | 932 | -932 |
|
||||
| 1472 | defender | last_player_standing | 0 | 944 | -944 |
|
||||
| 11754 | defender | last_player_standing | 0 | 1000 | -1000 |
|
||||
| 7212 | defender | last_player_standing | 0 | 1273 | -1273 |
|
||||
| 399 | defender | last_player_standing | 0 | 1315 | -1315 |
|
||||
| 11012 | defender | last_player_standing | 0 | 1347 | -1347 |
|
||||
| 3701 | defender | last_player_standing | 0 | 1348 | -1348 |
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
action_seq,attacker_factions,attacker_player,defender_factions,defender_player,ai_vs_ai,real_winner,sim_winner,winner_match,real_round,sim_round,real_attacker_troops,real_defender_troops,sim_attacker_troops,sim_defender_troops
|
||||
306,1,AI,2,AI,true,attacker,attacker,true,0,19,1454,0,1388,0
|
||||
307,2,AI,1,AI,true,defender,defender,true,0,16,0,699,0,648
|
||||
399,2,AI,1,AI,true,defender,defender,true,0,6,0,1310,0,1315
|
||||
631,1,AI,2,AI,true,defender,defender,true,0,5,0,3766,0,3654
|
||||
1326,9,AI,6,AI,true,attacker,attacker,true,0,11,1715,0,1701,0
|
||||
1472,8,AI,3,human,false,defender,defender,true,0,31,0,1593,0,944
|
||||
2437,2,AI,1,AI,true,attacker,attacker,true,0,16,3146,0,3247,0
|
||||
2584,1,AI,2,AI,true,defender,defender,true,0,31,0,2028,0,168
|
||||
2654,2,AI,1,AI,true,defender,defender,true,0,28,0,335,0,467
|
||||
3536,1,AI,2,AI,true,attacker,attacker,true,0,12,2172,0,2528,0
|
||||
3701,8,AI,2,AI,true,defender,defender,true,0,30,0,2819,0,1348
|
||||
4298,2,AI,1,AI,true,defender,defender,true,0,3,0,3514,0,1977
|
||||
4576,2,AI,1,AI,true,defender,defender,true,0,4,0,2028,0,1986
|
||||
6014,2,AI,8,AI,true,defender,defender,true,0,6,0,1942,0,2252
|
||||
6248,7,AI,9,AI,true,attacker,attacker,true,0,25,520,0,676,0
|
||||
6408,4,human,7,AI,false,attacker,attacker,true,0,11,4156,0,4020,0
|
||||
6705,1,AI,2,AI,true,defender,defender,true,0,31,0,3316,0,535
|
||||
7212,2,AI,8,AI,true,defender,defender,true,0,8,0,1253,0,1273
|
||||
8217,8,AI,2,AI,true,attacker,attacker,true,0,22,920,0,2167,0
|
||||
8394,2,AI,8,AI,true,attacker,attacker,true,0,9,2449,0,3186,0
|
||||
8628,3,human,8,AI,false,attacker,attacker,true,0,5,2874,0,2760,0
|
||||
9975,2,AI,1,AI,true,defender,defender,true,0,4,0,7420,0,7840
|
||||
10229,5,AI,7,AI,true,attacker,attacker,true,0,23,2135,0,1816,0
|
||||
10631,1,AI,2,AI,true,attacker,attacker,true,0,1,4000,0,4000,0
|
||||
11011,2,AI,1,AI,true,defender,defender,true,0,5,0,3532,0,3658
|
||||
11012,9,AI,6,AI,true,defender,defender,true,0,30,0,846,0,1347
|
||||
11112,5,AI,4,human,false,defender,defender,true,0,3,0,7595,0,7595
|
||||
11297,1,AI,2,AI,true,attacker,attacker,true,0,1,2400,0,2400,0
|
||||
11469,5,AI,9,AI,true,defender,defender,true,0,8,0,787,0,1489
|
||||
11470,7,AI,5,AI,true,attacker,attacker,true,0,17,3220,0,3425,0
|
||||
11565,5,AI,7,AI,true,defender,defender,true,0,31,190,3420,78,3600
|
||||
11662,4,human,9,AI,false,attacker,attacker,true,0,8,7326,0,6624,0
|
||||
11754,7,AI,4,human,false,attacker,defender,false,0,30,2119,0,0,1000
|
||||
11941,3,human,5,AI,false,attacker,attacker,true,0,6,4901,0,4777,0
|
||||
11942,4,human,7,AI,false,attacker,attacker,true,0,5,8840,0,8572,0
|
||||
12136,9,AI,7,AI,true,attacker,attacker,true,0,21,1829,0,1581,0
|
||||
12137,2,AI,1,AI,true,defender,defender,true,0,8,0,1894,0,1892
|
||||
12138,3,human,5,AI,false,attacker,attacker,true,0,14,2960,0,2512,0
|
||||
12765,6,AI,9,AI,true,defender,defender,true,0,11,0,1773,0,1763
|
||||
13752,2,AI,1,AI,true,defender,defender,true,0,7,0,943,0,932
|
||||
|
+56
@@ -0,0 +1,56 @@
|
||||
# All-40 Real vs STANDARD Simulation
|
||||
|
||||
This table compares the saved-game Shardok winner side with a fresh
|
||||
STANDARD-vs-STANDARD simulator run from the extracted battle setup.
|
||||
Faction `3` and faction `4` are marked as human players; all other
|
||||
factions are marked as AI players.
|
||||
|
||||
- Battles checked: 40
|
||||
- Known real outcomes: 40
|
||||
- Winner-side matches on known outcomes: 39/40
|
||||
- Real saved-game winners: 19 attacker, 21 defender, 0 unknown
|
||||
- Sim baseline winners: 18 attacker, 22 defender
|
||||
- Battles involving a human side: 9
|
||||
|
||||
| Action seq | Attacker | Defender | Real winner | Sim winner | Match | Real troops | Sim troops |
|
||||
| ---: | --- | --- | --- | --- | --- | ---: | ---: |
|
||||
| 306 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 1454 vs 0 | 1388 vs 0 |
|
||||
| 307 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 699 | 0 vs 648 |
|
||||
| 399 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 1310 | 0 vs 1315 |
|
||||
| 631 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 3766 | 0 vs 3654 |
|
||||
| 1326 | 9 (AI) | 6 (AI) | attacker | attacker | yes | 1715 vs 0 | 1701 vs 0 |
|
||||
| 1472 | 8 (AI) | 3 (human) | defender | defender | yes | 0 vs 1593 | 0 vs 944 |
|
||||
| 2437 | 2 (AI) | 1 (AI) | attacker | attacker | yes | 3146 vs 0 | 3247 vs 0 |
|
||||
| 2584 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 2028 | 0 vs 168 |
|
||||
| 2654 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 335 | 0 vs 467 |
|
||||
| 3536 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 2172 vs 0 | 2528 vs 0 |
|
||||
| 3701 | 8 (AI) | 2 (AI) | defender | defender | yes | 0 vs 2819 | 0 vs 1348 |
|
||||
| 4298 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 3514 | 0 vs 1977 |
|
||||
| 4576 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 2028 | 0 vs 1986 |
|
||||
| 6014 | 2 (AI) | 8 (AI) | defender | defender | yes | 0 vs 1942 | 0 vs 2252 |
|
||||
| 6248 | 7 (AI) | 9 (AI) | attacker | attacker | yes | 520 vs 0 | 676 vs 0 |
|
||||
| 6408 | 4 (human) | 7 (AI) | attacker | attacker | yes | 4156 vs 0 | 4020 vs 0 |
|
||||
| 6705 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 3316 | 0 vs 535 |
|
||||
| 7212 | 2 (AI) | 8 (AI) | defender | defender | yes | 0 vs 1253 | 0 vs 1273 |
|
||||
| 8217 | 8 (AI) | 2 (AI) | attacker | attacker | yes | 920 vs 0 | 2167 vs 0 |
|
||||
| 8394 | 2 (AI) | 8 (AI) | attacker | attacker | yes | 2449 vs 0 | 3186 vs 0 |
|
||||
| 8628 | 3 (human) | 8 (AI) | attacker | attacker | yes | 2874 vs 0 | 2760 vs 0 |
|
||||
| 9975 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 7420 | 0 vs 7840 |
|
||||
| 10229 | 5 (AI) | 7 (AI) | attacker | attacker | yes | 2135 vs 0 | 1816 vs 0 |
|
||||
| 10631 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 4000 vs 0 | 4000 vs 0 |
|
||||
| 11011 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 3532 | 0 vs 3658 |
|
||||
| 11012 | 9 (AI) | 6 (AI) | defender | defender | yes | 0 vs 846 | 0 vs 1347 |
|
||||
| 11112 | 5 (AI) | 4 (human) | defender | defender | yes | 0 vs 7595 | 0 vs 7595 |
|
||||
| 11297 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 2400 vs 0 | 2400 vs 0 |
|
||||
| 11469 | 5 (AI) | 9 (AI) | defender | defender | yes | 0 vs 787 | 0 vs 1489 |
|
||||
| 11470 | 7 (AI) | 5 (AI) | attacker | attacker | yes | 3220 vs 0 | 3425 vs 0 |
|
||||
| 11565 | 5 (AI) | 7 (AI) | defender | defender | yes | 190 vs 3420 | 78 vs 3600 |
|
||||
| 11662 | 4 (human) | 9 (AI) | attacker | attacker | yes | 7326 vs 0 | 6624 vs 0 |
|
||||
| 11754 | 7 (AI) | 4 (human) | attacker | defender | no | 2119 vs 0 | 0 vs 1000 |
|
||||
| 11941 | 3 (human) | 5 (AI) | attacker | attacker | yes | 4901 vs 0 | 4777 vs 0 |
|
||||
| 11942 | 4 (human) | 7 (AI) | attacker | attacker | yes | 8840 vs 0 | 8572 vs 0 |
|
||||
| 12136 | 9 (AI) | 7 (AI) | attacker | attacker | yes | 1829 vs 0 | 1581 vs 0 |
|
||||
| 12137 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 1894 | 0 vs 1892 |
|
||||
| 12138 | 3 (human) | 5 (AI) | attacker | attacker | yes | 2960 vs 0 | 2512 vs 0 |
|
||||
| 12765 | 6 (AI) | 9 (AI) | defender | defender | yes | 0 vs 1773 | 0 vs 1763 |
|
||||
| 13752 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 943 | 0 vs 932 |
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# ID Scoring Experiments, Seeds 1-4
|
||||
|
||||
These results use `id_standard_seed1.json` as the base generated battle shape:
|
||||
|
||||
- Map: `Alah`
|
||||
- Max rounds: 10
|
||||
- Setup: AI placement, forced single-option commands enabled
|
||||
- Attacker: iterative deepening, six longbowmen with professions 1-6
|
||||
- Defender: iterative deepening, six light infantry units with no attached heroes
|
||||
|
||||
The saved game zip at the repository root contains real Shardok battles, but the available
|
||||
`shardok_state` rows are latest-state snapshots and the sampled states are terminal. They are
|
||||
useful for saved-state ingestion checks, not for live AI decision win-rate measurement.
|
||||
|
||||
## Baseline
|
||||
|
||||
`STANDARD` attacker vs `STANDARD` defender:
|
||||
|
||||
- Attacker wins: 0 / 4
|
||||
- Defender wins: 4 / 4
|
||||
- Attacker win rate: 0%
|
||||
- Average defender surviving units: 6.00
|
||||
- Average defender surviving troops: 3510.25
|
||||
|
||||
Raw rows are recorded in `id_standard_baseline_seed1_4.csv`.
|
||||
|
||||
## Candidate Results
|
||||
|
||||
| Attacker scoring | Defender scoring | Attacker wins | Defender wins | Avg defender units | Avg defender troops | Notes |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
||||
| `NORMALIZED` | `STANDARD` | 0 | 4 | 5.50 | 3224.00 | Better casualties than baseline, no win-rate movement. |
|
||||
| `MCTS_OPTIMIZED` | `STANDARD` | 0 | 4 | 5.50 | 3503.50 | Similar troop outcome to baseline. |
|
||||
| `STANDARD` with attacker unit decay removed | `STANDARD` | 0 | 4 | 6.00 | 3204.25 | Best casualty signal in this small sample, no win-rate movement. |
|
||||
|
||||
The attacker unit-decay experiment changes `StandardAIScoreCalculator::CombineAttackerScores` to
|
||||
use a constant unit multiplier, matching the defender hold-castle scoring behavior. The rationale is
|
||||
the same as the existing defender-side comment: decaying unit value late in the battle can make
|
||||
tactical actions look worse than waiting when a side is behind on unit value.
|
||||
|
||||
## Candidate Raw Rows
|
||||
|
||||
### `NORMALIZED` Attacker vs `STANDARD` Defender
|
||||
|
||||
```csv
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,NORMALIZED,STANDARD,defender,last_player_standing,9,121,60,43,2.53333,3.44186,0,5,0,2665
|
||||
2,NORMALIZED,STANDARD,defender,last_player_standing,10,129,64,47,2.50000,3.46809,0,6,0,3102
|
||||
3,NORMALIZED,STANDARD,defender,last_player_standing,9,116,59,41,2.59322,3.31707,0,6,0,3752
|
||||
4,NORMALIZED,STANDARD,defender,last_player_standing,9,114,63,34,2.47619,3.47059,0,5,0,3377
|
||||
```
|
||||
|
||||
### `MCTS_OPTIMIZED` Attacker vs `STANDARD` Defender
|
||||
|
||||
```csv
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,129,64,46,2.48438,3.54348,0,6,0,3203
|
||||
2,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,119,62,40,2.51613,3.22500,0,6,0,3559
|
||||
3,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,115,62,35,2.67742,3.37143,0,5,0,3969
|
||||
4,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,114,63,34,2.41270,3.41176,0,5,0,3283
|
||||
```
|
||||
|
||||
### `STANDARD` Attacker With Unit Decay Removed vs `STANDARD` Defender
|
||||
|
||||
```csv
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,10,135,65,51,2.53846,3.45098,0,6,0,2707
|
||||
2,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,130,64,48,2.48438,3.58333,0,6,0,3149
|
||||
3,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,129,63,45,2.57143,3.44444,0,6,0,3401
|
||||
4,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,129,62,46,2.66129,3.41304,0,6,0,3560
|
||||
```
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,STANDARD,STANDARD,defender,last_player_standing,9,130,65,47,2.52308,3.61702,0,6,0,3376
|
||||
2,STANDARD,STANDARD,defender,last_player_standing,9,130,63,47,2.58730,3.29787,0,6,0,3704
|
||||
3,STANDARD,STANDARD,defender,last_player_standing,9,129,63,45,2.58730,3.44444,0,6,0,3401
|
||||
4,STANDARD,STANDARD,defender,last_player_standing,9,129,62,46,2.70968,3.39130,0,6,0,3560
|
||||
|
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"map_name": "Alah",
|
||||
"month": 4,
|
||||
"max_rounds": 10,
|
||||
"random_seed": 1,
|
||||
"use_configured_placements": false,
|
||||
"apply_forced_commands": true,
|
||||
"attacker": {
|
||||
"ai_algorithm": "ITERATIVE_DEEPENING",
|
||||
"scoring_calculator": "STANDARD",
|
||||
"units": [
|
||||
{ "profession": 1, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 2, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 3, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 4, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 5, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 6, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } }
|
||||
]
|
||||
},
|
||||
"defender": {
|
||||
"ai_algorithm": "ITERATIVE_DEEPENING",
|
||||
"scoring_calculator": "STANDARD",
|
||||
"units": [
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
variant,attacker_scoring,defender_scoring,shardok_game_id,action_seq,attacker_units,defender_units,attacker_initial_troops,defender_initial_troops,attacker_troop_ratio,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,last_player_standing,Defender won by eliminating all attackers,31,192,67,85,3.61194,3.61176,0,1,0,685,18.768
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,82,32,20,3.09375,2.3,0,1,0,324,5.722
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.89623,1,3,450,2454,30.201
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.55,3.90909,0,4,0,2991,9.564
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.18519,0,5,0,4037,10.772
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,198,69,84,3.13043,3.5,1,2,57,378,23.999
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,18,267,82,158,3.30488,3.42405,0,5,0,2948,73.020
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,6,78,10,52,3.2,3.53846,0,7,0,3832,21.143
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,33.775
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,428,138,247,2.52174,3.39271,1,6,375,4511,115.585
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,31,255,40,179,2.825,3.45251,0,4,0,2358,56.109
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,599,247,309,2,3.69579,4,6,2914,4085,128.531
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.33108,4,3,629,1771,41.509
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,27.933
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,12,86,32,27,3.25,3.37037,2,0,1455,0,7.670
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,15,145,96,24,3.38542,2.54167,5,0,3832,0,30.677
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,attacker,last_player_standing,Attacker won by eliminating all defenders,28,219,101,81,2.9802,3.48148,3,0,2150,0,40.168
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.591
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,24,185,90,53,3.46667,3.37736,3,0,1944,0,21.446
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,20,130,74,26,3.41892,2.84615,4,0,2552,0,25.721
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.159
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,5,115,26,65,3.03846,2.98462,0,11,0,7033,33.494
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,160,81,13,3.85185,2.53846,1,1,56,727,15.586
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,10,104,55,27,2.98182,2.62963,5,0,3955,0,17.981
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,274,80,151,2.3625,3.75497,1,4,120,2749,65.354
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,112,30,48,2.9,3.3125,0,2,0,1328,12.932
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,10,60,3.4,3.05,0,11,0,9248,26.113
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,14,110,46,39,2.95652,3.4359,3,0,1569,0,20.096
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,27,159,73,34,3.52055,3.05882,3,0,2160,0,19.694
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,356,177,112,2.31073,3.47321,3,2,2267,952,18.046
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.555
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,11,174,111,41,3.1982,3.36585,9,0,7070,0,47.504
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,7,55,20,22,3.65,3.54545,0,3,0,1624,10.181
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,290,186,69,3.46237,3.52174,1,2,165,299,60.206
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,12,232,164,47,2.94512,3.59574,12,0,7940,0,77.700
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,15,98,50,25,3.18,3.12,2,0,1384,0,14.063
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,148,67,43,2.34328,2.55814,1,3,214,2282,19.123
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,6.678
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,30,108,40,30,3.5,2.86667,0,2,0,1678,9.069
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.15385,3.98374,0,2,0,1114,26.389
|
||||
|
+102
@@ -0,0 +1,102 @@
|
||||
# Real Battle All-40 Scoring Experiments
|
||||
|
||||
> Superseded: these results were generated before the simulator preserved explicit zero-size
|
||||
> battalion slots. Saved-game configs with empty slots could therefore receive extra full-capacity
|
||||
> units. Regenerate this matrix after the zero-size battalion fix before comparing scoring changes.
|
||||
|
||||
These runs use all 40 saved battle creation payloads from `game_389ffb1901903118.zip`.
|
||||
Each config was extracted from `new_battle_proto` plus the nearest prior Eagle `GameState`
|
||||
snapshot. The matrix uses `max_rounds = 31`, `random_seed = 1`, AI setup placement, and forced
|
||||
single-option commands.
|
||||
|
||||
Shardok has no draw result for these siege battles: if the attacker has not won after 31 rounds,
|
||||
the defender wins. The simulator now records those cutoffs as `max_rounds_reached` defender wins
|
||||
instead of `draw` / `Game incomplete`.
|
||||
|
||||
Raw rows are recorded in `real_battle_all40_scoring_matrix.csv`.
|
||||
|
||||
## Full Matrix Results
|
||||
|
||||
| Variant | Attacker wins | Defender wins | Max-round defender wins | Runtime |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `STANDARD` attacker vs `STANDARD` defender | 11 | 29 | 15 | 8.7 min |
|
||||
| `NORMALIZED` attacker vs `STANDARD` defender | 1 | 39 | 23 | 8.7 min |
|
||||
| `MCTS_OPTIMIZED` attacker vs `STANDARD` defender | 10 | 30 | 14 | 8.7 min |
|
||||
|
||||
`STANDARD` here is the current branch implementation, including the attacker-side change that keeps
|
||||
unit value relevant through the end of the battle instead of decaying it by rounds remaining.
|
||||
|
||||
`NORMALIZED` is much worse for ID attacker scoring on this real set. It converts 10 baseline
|
||||
attacker wins into defender wins and is not a promising direction for attacker scoring.
|
||||
|
||||
`MCTS_OPTIMIZED` is closer to neutral but still slightly worse than current `STANDARD` for ID
|
||||
attacker scoring on this set. It loses battle `6705`, which current `STANDARD` wins, and does not
|
||||
add any attacker wins.
|
||||
|
||||
## Outcome Changes Versus Baseline
|
||||
|
||||
### `NORMALIZED` Attacker
|
||||
|
||||
- `6248`: attacker last-player-standing win -> defender last-player-standing win
|
||||
- `6408`: attacker last-player-standing win -> defender max-round win
|
||||
- `6705`: attacker last-player-standing win -> defender last-player-standing win
|
||||
- `8217`: attacker last-player-standing win -> defender max-round win
|
||||
- `8394`: attacker last-player-standing win -> defender max-round win
|
||||
- `11297`: attacker last-player-standing win -> defender last-player-standing win
|
||||
- `11469`: attacker last-player-standing win -> defender max-round win
|
||||
- `11662`: attacker last-player-standing win -> defender max-round win
|
||||
- `11942`: attacker last-player-standing win -> defender max-round win
|
||||
- `12136`: attacker last-player-standing win -> defender last-player-standing win
|
||||
|
||||
Other changes only alter how the defender wins, not who wins.
|
||||
|
||||
### `MCTS_OPTIMIZED` Attacker
|
||||
|
||||
- `6705`: attacker last-player-standing win -> defender last-player-standing win
|
||||
|
||||
Other changes only alter how the defender wins, not who wins.
|
||||
|
||||
## Close-Battle Follow-up
|
||||
|
||||
The closest current-baseline defender wins by surviving troop margin were:
|
||||
|
||||
- `11941`: defender max-round win, attacker 165 troops vs defender 299
|
||||
- `1472`: defender max-round win, attacker 57 troops vs defender 378
|
||||
- `307`: defender last-player-standing win with 324 defender troops left
|
||||
- `7212`: defender max-round win, attacker 1156 troops vs defender 1800
|
||||
- `10229`: defender max-round win, attacker 56 troops vs defender 727
|
||||
|
||||
Before correcting the no-draw rule, earlier max-20 data made several cutoffs look like draws. Those
|
||||
rows are superseded for effectiveness comparisons by the max-31 matrix above.
|
||||
|
||||
## Scoring Tweak Tested
|
||||
|
||||
The scoring tweak included in this branch removes the attacker-side late-battle unit-value decay in
|
||||
`StandardAIScoreCalculator::CombineAttackerScores`.
|
||||
|
||||
Rationale:
|
||||
|
||||
- Defender hold-castle scoring already removed this decay because it made tactical actions look
|
||||
worse than waiting when behind on unit value.
|
||||
- The close real battles showed attackers failing to convert material/position before the round
|
||||
limit.
|
||||
- Keeping unit value meaningful late in the battle should make the attacker continue seeking
|
||||
decisive tactical progress instead of undervaluing combat.
|
||||
|
||||
On the original five close production-length cases, this tweak preserved existing attacker wins and
|
||||
flipped `11941` from defender win to attacker win:
|
||||
|
||||
| Battle | Production max-40 | No attacker unit decay |
|
||||
| --- | --- | --- |
|
||||
| `306` | attacker win, 239 attacker troops left | attacker win, 239 attacker troops left |
|
||||
| `11469` | attacker win, 2166 attacker troops left | attacker win, 2160 attacker troops left |
|
||||
| `11941` | defender win, 131 defender troops left | attacker win, 1052 attacker troops left |
|
||||
| `6705` | attacker win, 2062 attacker troops left | attacker win, 2393 attacker troops left |
|
||||
| `307` | defender win, 324 defender troops left | defender win, 324 defender troops left |
|
||||
|
||||
Detailed rows are recorded in `real_battle_closest_max40_no_attacker_unit_decay.csv`.
|
||||
|
||||
The final-round flee tweak was also tested and is recorded in
|
||||
`real_battle_closest_final_round_flee_compare.csv` and
|
||||
`real_battle_closest_final_round_flee_plus_no_decay.csv`. It did not outperform the cleaner
|
||||
production-length no-decay result, so it is not included as a code change.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
variant,attacker_scoring,defender_scoring,shardok_game_id,action_seq,attacker_units,defender_units,attacker_initial_troops,defender_initial_troops,attacker_troop_ratio,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,last_player_standing,Defender won by eliminating all attackers,31,192,67,85,3.61194,3.61176,0,1,0,685,18.768
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,82,32,20,3.09375,2.3,0,1,0,324,5.722
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.89623,1,3,450,2454,30.201
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.55,3.90909,0,4,0,2991,9.564
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.18519,0,5,0,4037,10.772
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,198,69,84,3.13043,3.5,1,2,57,378,23.999
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,18,267,82,158,3.30488,3.42405,0,5,0,2948,73.020
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,6,78,10,52,3.2,3.53846,0,7,0,3832,21.143
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,33.775
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,428,138,247,2.52174,3.39271,1,6,375,4511,115.585
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,31,255,40,179,2.825,3.45251,0,4,0,2358,56.109
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,599,247,309,2,3.69579,4,6,2914,4085,128.531
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.33108,4,3,629,1771,41.509
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,27.933
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,12,86,32,27,3.25,3.37037,2,0,1455,0,7.670
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,15,145,96,24,3.38542,2.54167,5,0,3832,0,30.677
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,attacker,last_player_standing,Attacker won by eliminating all defenders,28,219,101,81,2.9802,3.48148,3,0,2150,0,40.168
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.591
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,24,185,90,53,3.46667,3.37736,3,0,1944,0,21.446
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,20,130,74,26,3.41892,2.84615,4,0,2552,0,25.721
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.159
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,5,115,26,65,3.03846,2.98462,0,11,0,7033,33.494
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,160,81,13,3.85185,2.53846,1,1,56,727,15.586
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,10,104,55,27,2.98182,2.62963,5,0,3955,0,17.981
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,274,80,151,2.3625,3.75497,1,4,120,2749,65.354
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,112,30,48,2.9,3.3125,0,2,0,1328,12.932
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,10,60,3.4,3.05,0,11,0,9248,26.113
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,14,110,46,39,2.95652,3.4359,3,0,1569,0,20.096
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,27,159,73,34,3.52055,3.05882,3,0,2160,0,19.694
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,356,177,112,2.31073,3.47321,3,2,2267,952,18.046
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.555
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,11,174,111,41,3.1982,3.36585,9,0,7070,0,47.504
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,7,55,20,22,3.65,3.54545,0,3,0,1624,10.181
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,290,186,69,3.46237,3.52174,1,2,165,299,60.206
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,12,232,164,47,2.94512,3.59574,12,0,7940,0,77.700
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,15,98,50,25,3.18,3.12,2,0,1384,0,14.063
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,148,67,43,2.34328,2.55814,1,3,214,2282,19.123
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,6.678
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,30,108,40,30,3.5,2.86667,0,2,0,1678,9.069
|
||||
baseline,STANDARD,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.15385,3.98374,0,2,0,1114,26.389
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,190,72,93,3.56944,3.44086,1,2,135,968,14.687
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,129,62,31,2.24194,2,1,1,800,1000,2.627
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,189,45,107,2,3.94393,1,3,450,2454,29.873
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,69,19,33,3.15789,3.93939,0,4,0,2985,9.697
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,64,18,29,2.66667,3.24138,0,5,0,4026,11.132
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,218,76,96,2.98684,3.38542,1,2,443,511,26.824
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,22,260,67,160,2.32836,3.61875,0,4,0,3090,62.598
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,5,66,9,42,3.22222,3.52381,0,7,0,3979,18.916
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,37.896
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,504,126,338,2.57937,3.25148,1,7,193,4379,148.897
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,30,234,38,168,2.55263,3.28571,0,4,0,1930,54.692
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,566,236,286,2,3.63636,3,6,2335,3974,116.403
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.26351,4,3,629,1771,41.599
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,28.149
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,defender,last_player_standing,Defender won by eliminating all attackers,17,110,23,52,2.43478,3.40385,0,2,0,974,4.416
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,318,200,85,3.32,3.50588,3,2,2400,1056,71.256
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,defender,last_player_standing,Defender won by eliminating all attackers,8,69,22,30,3.09091,3.5,0,5,0,3915,13.735
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.036
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,236,133,59,3.45865,3.16949,2,1,1120,72,29.584
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,249,144,46,3.32639,2.34783,3,1,2560,143,31.143
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.578
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,25,568,52,472,2.36538,2.625,0,10,0,6047,213.060
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,156,77,13,3.79221,2.53846,1,1,56,727,15.511
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,9,100,60,21,3.08333,2.80952,5,0,3922,0,19.207
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,278,85,151,2.38824,3.78808,1,4,152,2798,63.634
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,17,99,29,44,2.82759,3.22727,0,1,0,713,12.109
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,17,306,24,251,2.58333,2.96016,0,11,0,9231,113.880
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,30,195,88,59,3,3,0,1,0,376,23.692
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,216,104,55,3.66346,2.96364,1,1,529,622,18.457
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,365,187,108,2.04278,3.23148,3,2,2800,1800,17.991
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,2,0,0,4,0,3600,0.820
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,635,434,155,2.04378,3.54194,7,3,5795,2600,78.499
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,8,65,23,26,3.26087,3.57692,0,3,0,1516,10.700
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,209,126,61,3.09524,2.98361,1,2,532,599,36.122
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,542,385,125,2.53247,3.808,9,3,6230,2008,109.176
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,defender,last_player_standing,Defender won by eliminating all attackers,30,205,59,99,3.20339,3.66667,0,2,0,791,10.465
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,last_player_standing,Defender won by eliminating all attackers,5,34,12,10,3.75,2.7,0,3,0,2201,6.037
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,7.590
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,135,67,31,2.1791,2.93548,1,2,1000,1800,3.512
|
||||
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,6,43,15,14,3.53333,3.64286,0,2,0,1114,3.460
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,169,69,65,3.4058,3.18462,1,1,173,644,20.231
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,77,30,19,3.2,2.31579,0,1,0,391,5.495
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.90566,1,3,450,2454,30.740
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.5,3.87879,0,4,0,2991,10.442
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.03704,0,5,0,4037,10.816
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,219,76,95,2.98684,3.41053,1,2,499,816,24.422
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,7,102,37,49,3.05405,3.4898,0,5,0,3256,26.232
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,5,66,9,42,3.22222,3.47619,0,7,0,3979,18.733
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.16129,3,2,2599,726,37.141
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,last_player_standing,Defender won by eliminating all attackers,27,304,85,178,2.76471,3.19101,0,8,0,4413,92.026
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,13,141,22,100,2.95455,3.38,0,5,0,1966,38.309
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,573,223,306,2.03587,3.53922,1,6,773,3974,122.498
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.39189,4,3,629,1771,42.224
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.3038,2,3,1512,2600,27.854
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,10,75,28,24,3.28571,3.45833,2,0,1417,0,6.284
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,13,132,88,19,3.31818,2.63158,4,0,3081,0,25.109
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,defender,last_player_standing,Defender won by eliminating all attackers,30,214,98,82,2.92857,3.45122,0,3,0,150,42.999
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.39516,2,2,1156,1800,15.332
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,25,184,96,55,3.42708,3.36364,3,0,1985,0,23.348
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,11,97,55,20,3.18182,2.45,4,0,2834,0,17.372
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,6.067
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,6,131,28,79,2.92857,2.94937,0,10,0,6855,40.968
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,157,78,13,3.79487,2.53846,1,1,56,727,15.301
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,9,100,60,21,2.88333,2.80952,5,0,3921,0,18.076
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,275,80,152,2.3625,3.72368,1,4,120,2749,67.360
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,111,30,48,2.9,3.25,0,2,0,1356,13.443
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,11,59,3.27273,2.9661,0,11,0,9249,28.253
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,12,101,42,35,2.78571,3.42857,3,0,1479,0,17.617
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,25,148,67,31,3.44776,3.19355,3,0,2061,0,19.041
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,359,177,115,2.28249,3.46087,3,2,2136,859,18.446
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.952
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,12,182,120,40,3.19167,3.2,9,0,6817,0,47.676
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,14,101,28,48,3,3.3125,0,2,0,1191,12.998
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,261,154,66,3.32468,3.21212,1,1,299,258,41.484
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,11,202,144,39,3.05556,3.46154,12,0,7852,0,68.062
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,16,104,53,28,3,3.14286,3,0,1186,0,13.042
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,last_player_standing,Defender won by eliminating all attackers,5,35,13,10,3.61538,2.6,0,3,0,2201,5.724
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,7.197
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,31,135,51,45,3.27451,2.62222,0,2,0,1463,9.382
|
||||
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.17949,4.18699,0,2,0,1114,23.782
|
||||
|
+6
@@ -0,0 +1,6 @@
|
||||
variant,action_seq,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
final_round_flee_compare,306,draw,draw,21,121,43,45,3.2093,3.46667,1,1,298,312,4.868
|
||||
final_round_flee_compare,11469,draw,draw,21,130,55,33,3.69091,3.33333,1,1,433,324,12.447
|
||||
final_round_flee_compare,11941,draw,draw,21,198,125,43,3.608,3.4186,2,1,453,373,33.665
|
||||
final_round_flee_compare,6705,draw,draw,21,187,98,61,3.28571,3.40984,1,1,796,377,36.167
|
||||
final_round_flee_compare,307,defender,last_player_standing,20,82,32,20,3.3125,2.3,0,1,0,324,3.675
|
||||
|
+6
@@ -0,0 +1,6 @@
|
||||
variant,action_seq,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
flee_compare_no_attacker_unit_decay,306,draw,draw,21,123,43,46,3.37209,3.45652,2,1,386,286,5.172
|
||||
flee_compare_no_attacker_unit_decay,11469,draw,draw,21,130,55,33,3.65455,3.33333,1,1,391,352,12.264
|
||||
flee_compare_no_attacker_unit_decay,11941,draw,draw,21,169,103,42,3.5534,3.21429,1,1,337,282,30.630
|
||||
flee_compare_no_attacker_unit_decay,6705,draw,draw,21,172,89,55,3.29213,3.14545,2,1,824,265,32.483
|
||||
flee_compare_no_attacker_unit_decay,307,defender,last_player_standing,20,82,32,20,3.1875,2.3,0,1,0,324,3.550
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user