Compare commits

..
Author SHA1 Message Date
adminandClaude e2f7b82ecd Eliminate expensive proto conversions in ShardokAction
Refactored ShardokAction to store a pointer to ShardokCommand instead of
eagerly converting to CommandProto. This eliminates millions of expensive
GetCommandProto() calls during MCTS search.

Key changes:
- Added forward declaration for ShardokCommand in ShardokAction.hpp
- New constructor: ShardokAction(const ShardokCommand* command, size_t index)
- Changed internal storage: const ShardokCommand* command_ (non-owning pointer)
- Made getCommand() lazy: only converts Command→Proto when actually needed
- Updated clone() to copy command pointer (cheap) instead of proto
- Updated BUILD.bazel visibility for shardok_command target

Performance impact: 2.1x speedup (870K → 1.8M visits)
Combined with previous optimizations: 55x total speedup (33K → 1.8M visits)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 19:10:07 -07:00
adminandClaude 4214610f33 Cache action objects to reduce allocation overhead
Caches the actual MCTSAction objects alongside legal actions data,
allowing fast cloning instead of reconstructing from command protos.

Changes:
- Added cachedActions vector to LegalActionsCache struct
- Modified getLegalActions() to clone cached actions when available
- Store cloned actions in cache after computing them

Impact:
- Reduces repeated action construction from cached data
- Performance similar to previous (~750K visits)
- Sets stage for eliminating expensive proto conversions

Note: This shows diminishing returns, suggesting proto conversion
is now the bottleneck (GetCommandProto() called on every action creation).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:59:32 -07:00
adminandClaude 339b2e8e63 Cache action weights to eliminate getActionWeights bottleneck
After adding transition caching, getActionWeights became the dominant
cost at 61% of runtime. This caches action weights alongside legal actions.

Changes:
- Added actionWeights vector to LegalActionsCache struct
- Modified getActionWeights() to:
  - Check cache for weights based on state hash
  - Return cached weights if available and size matches
  - Compute and store weights on cache miss

Performance impact:
- Additional 1.7x speedup on top of transition caching
- Total improvement: ~26x vs original (33K → 870K visits/search)
- Eliminates redundant AIHeuristicWeighting calculations for revisited states

The weights are deterministic for a given (state, actions) pair, making
them safe to cache. Since we already cache legal actions per state hash,
adding weights to the same cache entry is straightforward and effective.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:53:05 -07:00
adminandClaude cfe33bd7e4 Fix transition cache: store next state's engine in cache
CRITICAL FIX: The previous implementation stored transition mappings
(currentState, action) -> nextStateHash, but never stored nextStateHash's
engine in the cache. This caused 100% cache miss rate because lookups
for nextStateHash would always fail.

Changes:
- In applyAction(): After storing transition, also store next state's engine
  in legalActionsCache_[nextStateHash].engine
- In applyActionMutable(): Same fix

Why this matters:
Without this, the transition cache lookup chain would fail at:
1. Find transition: (stateA, action) -> stateB hash ✓
2. Look up stateB in cache to get engine ✗ (not in cache!)

Now stateB's engine is cached immediately when the transition is stored,
so future lookups of the same transition can retrieve the complete state.

Note: Statistics appear low/zero in test output because they're thread-local
and we only report from the main thread. Worker threads that do MCTS
simulations have separate caches. This doesn't affect correctness, only
visibility of metrics.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:43:34 -07:00
adminandClaude 6384419c51 Add transition caching to MCTS ShardokGameEngine
Implements state transition caching to avoid redundant PostCommand calls
during MCTS tree search. Key changes:

1. Added TransitionKey struct to cache (actionIndex, roll) tuples
2. Extended LegalActionsCache with actionResults map for transition storage
3. Modified applyAction() and applyActionMutable() to:
   - Check transition cache before applying actions
   - Store new transitions after PostCommand
4. Added thread-local statistics for transition cache hits/misses
5. Updated reportCacheStatistics() to display transition cache metrics
6. Use SequenceRandomGenerator with {0.5} for deterministic 50th percentile rolls
   (matching IterativeDeepening AI behavior)

Benefits:
- Eliminates redundant PostCommand calls for previously-seen state transitions
- Particularly valuable for MCTS which revisits states many times
- Cache is thread-local to avoid lock contention in multithreaded simulation
- Complements existing legal actions cache for comprehensive optimization

Testing:
- All MCTS-specific tests pass (ai_mcts_test, shardok_mcts_ai_basic_test)
- 12 of 13 integration tests pass
- Transition cache statistics visible in search output

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:03:58 -07:00
adminandGitHub aeb52042d4 Fix critical error-hiding fallback in AbstractMCTSAI (#4501)
Fixed issue in pre-existing code:

**Empty actions list in SelectSimulationAction (Line 404):** Now throws
instead of returning 0 (which would be an invalid index into an empty list)

**Root node validation (Lines 38-60):** Properly distinguishes between:
- null root → throws MCTSInternalError
- 0 actions (terminal state) → returns gracefully with default result
- 1 action → returns index 0 (legitimate early exit)
- Multiple actions but no children → throws (BuildMCTSTree bug)

**Defensive fallbacks retained:**
- FILTERED_RANDOM falls back to random from all actions (reasonable)
- BEST_IMMEDIATE falls back to first action (reasonable)

These fallbacks are acceptable defensive programming against overly
aggressive filtering and don't hide bugs.
2025-10-27 06:39:29 -07:00
7065288cf2 Heuristic simulation (#4494)
* bad heuristic

* move heuristic

* speed up the hash

* skip the filter

* Revert "skip the filter"

This reverts commit 487311538565ccadc3354163cca33ec134c740bb.

* setup tests pass

* apply heuristic weighting to exploration

* budget depends on command count

* more on integration tests

* fixes

* fix hardcoded playerId

* another try at the integration tests

* pass in the MCTS config but use ID for now

* gazelle

* oof

* Fix test calls to use MCTSConfig instead of maxPlayerFlips int

Update AIIntegrationTest to use the new ShardokAIClient API that takes
MCTSConfig object instead of int maxPlayerFlips.

Added helper function MakeMCTSConfig() to create config objects with
the appropriate maxPlayerFlips value.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* not these monstrosities

* not this either

* Replace error-hiding returns with MCTSInternalError exceptions

Create custom MCTSInternalError exception class for MCTS bugs that
should crash rather than silently continue. Applied to three locations:

1. Invalid action index in expansion (line 205)
2. Failed action application in expansion (line 220)
3. All actions filtered out in weighted heuristic simulation (line 492)

Previously these cases would return silently, hiding bugs. Now they
throw descriptive exceptions to make problems visible immediately.

* Fix remaining error-hiding fallbacks in new code

Three issues fixed in code added by this PR:

1. MCTSGameEngine.cpp:119 - WEIGHTED_HEURISTIC playout with all zero
   weights now throws instead of falling back to random

2. ShardokGameEngine.cpp:288 - Non-Shardok actions now throw instead
   of falling back to weight 1.0

3. ShardokGameEngine.cpp:276 - Non-Shardok states now throw instead
   of falling back to uniform weights

Moved MCTSInternalError class from AbstractMCTSAI.hpp to MCTSTypes.hpp
to avoid circular dependencies (mcts_game_engine can't depend on
abstract_mcts_ai, but both can depend on mcts_types).

All three cases properly crash with descriptive error messages.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-26 21:53:51 -07:00
60b4c4fcea Fix test isolation and state caching bugs (#4500)
Three fixes to prevent state pollution between tests and stale caches:

1. Clear global transposition table between tests
   - TranspositionTable is a global singleton that persists across tests
   - State from previous tests can affect subsequent test behavior
   - Now explicitly clearing in SetUp()

2. Clear thread-local APD cache between tests
   - ActionPointDistancesCache uses thread-local storage
   - Cache entries can persist across test runs on same thread
   - Now explicitly clearing in SetUp()

3. Fix unit setup to match production
   - Tests were setting can_flee=false, production uses true
   - Tests calculated food_remaining, production uses fixed 1000.0
   - Units with heroes can flee in production, tests should match

4. Invalidate hash cache when state is mutated
   - ShardokGameState caches hash for performance
   - When state mutates in-place via getMutableShardokState()
   - Hash cache must be invalidated to avoid stale values
   - Added invalidateHashCache() method

These bugs caused flaky tests and incorrect test behavior.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-26 19:37:00 -07:00
ce532b4b9a Fix critical MCTS player ID bugs (#4499)
* Fix critical MCTS player ID bugs

Three related fixes for incorrect player ID handling in MCTS:

1. ShardokMCTSAI was using hardcoded playerId=0 instead of actual player ID
   - Added playerId parameter to constructor
   - Pass actual playerId to AbstractMCTSAI
   - Impact: Player 1 AI was evaluating from Player 0's perspective

2. Root node player tracking was incorrect
   - Root node now uses initialState.currentPlayerId() instead of playerId_
   - Set isMaximizingPlayer based on whether current player matches search player
   - Impact: Incorrect player flip tracking when opponent moves first

3. ShardokAIClient wasn't passing playerId to ShardokMCTSAI
   - Added playerId as first parameter when constructing ShardokMCTSAI
   - Impact: Player ID never reached the MCTS algorithm

These are correctness bugs that affect multi-player MCTS behavior.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix test compilation errors - add missing playerId parameter

Update MCTS test files to use new constructor signature that includes
playerId parameter as the first argument.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-26 19:35:56 -07:00
adminandGitHub 9acf324ba1 Scala fix (#4498)
* build file generator

* really fix it

* not that
2025-10-26 15:36:22 -07:00
adminandGitHub 0382d08ed5 fix a build file issue with SettingsLoader (#4497) 2025-10-26 14:56:34 -07:00
adminandGitHub 2159f87dc9 don't reset alliances (#4496) 2025-10-26 14:53:56 -07:00
ca6770b237 Optimize HashBuffer with word-at-a-time implementation (#4495)
Replace byte-by-byte FNV-1a hashing with a faster implementation that
processes 8 bytes at a time. This significantly improves performance for
hashing large FlatBuffer objects while maintaining the same FNV-1a
algorithm and good distribution properties for hash table use.

Key changes:
- Process 8 bytes at once using word-sized operations
- Use memcpy to avoid alignment issues and enable compiler optimization
- Fall back to byte-by-byte processing for remaining bytes
- Keep the same function signature (HashBuffer) for API stability

All existing tests pass (111 C++ tests).

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-24 18:52:11 -07:00
adminandGitHub d80e5e413c max player flips set to 0 (#4493) 2025-10-24 06:42:09 -07:00
adminandGitHub 3e35e678b3 Adaptive MCTS (#4492)
* transposition table

* display paths

* tuning

* adaptive
2025-10-23 20:43:09 -07:00
adminandGitHub 0640ea7542 add new tests and implement adversarial version (#4488)
* add new tests and implement adversarial version

* adverserial problems

* a bunch of 2p fixes

* minmax instead of stochastic

* reasonable behavior

* policy config

* cleanup

* remove debug loggin

* more logging

* more unneeded logging

* more cleanup

* fix the tests

* more test fixes

* more test fixes

* Moar

* whoops
2025-10-23 19:32:08 -07:00
adminandGitHub bce577758f Faster placement (#4491)
* shorter time budget during setup phase

* revert build file changes
2025-10-22 22:23:57 -07:00
adminandGitHub ad8e34ec3d guesser fixes (#4490) 2025-10-22 11:40:19 -07:00
adminandGitHub 215ebbee24 Update ShardokAIClient to take maxPlayerFlips parameter and add some tests (#4489)
* partial

* just get the existing one passing

* fix caller
2025-10-22 08:04:58 -07:00
adminandGitHub 1b7b2a2332 MCTS optimized scoring (#4485)
* AI integration tests

* add the MCTS-optimized score calculator and enable MCTS

* fix the tests
2025-10-21 07:31:18 -07:00
adminandGitHub d5eb0e95c1 remove maxIterations and put back in the early exit (#4487)
* remove maxIterations and put back in the early exit

* set the integration test to manual for now
2025-10-21 07:02:33 -07:00
adminandGitHub f96780ac83 AI integration tests (#4486)
* AI integration tests

* don't check this in yet

* refactor

* the tests run but fail

* getting there

* big sigh*

* comment out the Normalized scorer

* revert

* don't set the cache directory

* more acceptable results

* fix the integration tests
2025-10-20 06:31:00 -07:00
adminandGitHub 837825eb90 AI shouldn't attack a faction with whom it has an alliance (#4484) 2025-10-19 08:33:45 -07:00
adminandGitHub 5ea2d7e4d7 perf optimizations (#4483)
* perf optimizations

* more optimizations
2025-10-19 07:07:58 -07:00
adminandGitHub ff9dd51418 oops (#4482) 2025-10-18 12:18:01 -07:00
adminandGitHub e609fcac17 Normalized scoring calculator (#4481)
* add a normalized scoring algorithm

* add a normalized scoring calculator

* no default

* small refactor

* it all builds

* pull it out

* helper functions

* abstract away shared functionality

* unneeded stuff

* oops

* more into base class

* more refactor
2025-10-18 12:16:41 -07:00
adminandGitHub 7e7c48315e Eliminate another try/catch (#4480)
* remove one more bad try/catch

* fix tests
2025-10-17 16:38:29 -07:00
adminandGitHub 126e26f8c0 Better encapsulation for AIScoringCalculator (#4479)
* fully encapsulated

* bad function
2025-10-17 14:56:41 -07:00
adminandGitHub bf0260dfc9 move command evaluation out to separate class (#4478)
* move command evaluation out to separate class

* header only

* don't create a scorer inside IterativeDeepeningAI

* yet more refactor

* missing one break
2025-10-17 09:35:05 -07:00
adminandGitHub 278a041d05 Refactor AIScoreCalculator to be a true object instead of static methods (#4471)
* convert ScoreCalculator to an object

* refactor into an object

* broken build

* cleaner interface

* cleanup

* use the abstract superclass

* hmm

* complete the refactor

* don't use internal properties of the scorer

* more removals

* yet more

* default to iterative deepening

* yet more
2025-10-16 19:45:29 -07:00
adminandGitHub 98ccac67c9 fix flaky integration test (#4477) 2025-10-16 10:42:37 -07:00
adminandGitHub 04bb8edac1 a bit of cleanup (#4476) 2025-10-16 10:19:00 -07:00
adminandGitHub 1b1d290ead Fix code highlighting for C++23 (#4475)
* upgrade bazelrc to c++23

* fix c++23 code highlighting issues
2025-10-16 09:25:46 -07:00
adminandGitHub 1848c46a0a remove a dead package (#4474) 2025-10-16 09:17:42 -07:00
adminandGitHub 5df1cb5412 Remove path compression and do some cleanup (#4472)
* remove path compression and clean up

* cleanup

* more unused

* tests

* std::next
2025-10-14 14:16:19 -07:00
adminandGitHub a7f4ef2d57 add some more logging (#4470) 2025-10-13 21:21:18 -07:00
7ee22fc988 Battle simulator (#4463)
* battle simulator

* Fix sample config to use correct battalion type and starting positions

Updated sample_config.json to match the correct defaults from
CreateDefaultPerfConfig():
- battalion_type_id: 4 (Heavy Infantry, not 1)
- Attackers: starting_position_index: 0 (not incremental 0-5)
- Defenders: starting_position_index: -1 (not incremental 0-5)

This ensures the sample config matches what --generate-config produces
and will work correctly when used with the simulator.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix battle simulator crashes

Two critical fixes to make the AI battle simulator work correctly:

1. **Engine lifecycle fix**: Refactored to use a single ShardokEngine instance
   throughout both setup and battle phases. Previously, we created a new
   engine for each phase, which caused command cache initialization issues
   when transitioning from setup to battle.

   - Modified RunSetupPhase() and RunBattlePhase() to take ShardokEngine&
   - Create engine once in RunBattle() and pass to both phases
   - Removed state update that was working around the multi-engine problem

2. **Month configuration fix**: Changed default month from 0 to 4 in sample
   config. Months are 1-indexed (January=1, December=12), and month 0 was
   causing assertion failures when IceAndSnowAdjustmentActionFactory tried
   to access monthly_weather[month-1], resulting in index -1.

The simulator now runs complete AI vs AI battles without crashing.

* Fix default month in config generation

Changed default month parameter from 0 to 4 in CreateDefaultPerfConfig().
This ensures that generated configs use a valid month value (months are
1-indexed: January=1, December=12).

* Add configurable battalion and hero stats to battle simulator

Major improvements to make battle configurations fully customizable:

1. **Extended protobuf schema**: Added BattalionConfig and HeroConfig messages
   to ai_battle_config.proto with all battalion and hero attributes:
   - Battalion: size, armament, training, morale
   - Hero: strength, agility, wisdom, charisma, constitution, bravery,
     integrity, ambition, vigor

2. **Smart defaults using battalion type capacity**: Removed hardcoded
   DEFAULT_BATTALION_SIZE constant. Now uses each battalion type's actual
   capacity as the default size, which varies by type (Light Infantry,
   Heavy Infantry, Longbowmen, etc.).

3. **Config-driven unit creation**: Updated AiBattleSimulator to read
   battalion and hero stats from config with GetOrDefault() helper that
   applies sensible defaults when values aren't specified (proto3 uses 0).

4. **Fixed perf config battalion types**: Corrected CreateDefaultPerfConfig()
   to match Unity's Perf button:
   - Attackers: Longbowmen (battalion_type_id: 4)
   - Defenders: Light Infantry (battalion_type_id: 0)
   Previously incorrectly generated both as Longbowmen.

All existing configs continue to work with default values, while new configs
can fully customize unit stats for testing different scenarios.

* state guessing

* more simulation stuff

* battle simulator now kinda simulating

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-13 18:53:43 -07:00
adminandGitHub e5fdfd25c8 separate hero and battalion stats (#4469)
* separate hero and battalion stats

* typo
2025-10-13 12:43:34 -07:00
adminandGitHub 12d74ae0f1 Revert "just breakpoint, don't exception when there are no results (#4461)" (#4468)
This reverts commit 5c042dd683.
2025-10-12 17:35:25 -07:00
adminandGitHub 47b63e7ad3 handle the case where there's no model or no available commands (#4467)
* handle the case where there's no model or no available commands

* a little better
2025-10-12 16:12:35 -07:00
adminandGitHub e116c7a5dc bad pattern match in AvailableHandleCapturedHeroCommandFactory (#4466) 2025-10-12 15:03:43 -07:00
adminandGitHub a8005aa099 Recon sets the acting province as acted (#4465) 2025-10-11 22:44:11 -07:00
adminandGitHub 86a309330f set morale in guessedState to 50, not 25 (#4464) 2025-10-11 14:48:50 -07:00
db9f2052c6 Fix debug output to use stderr instead of stdout (#4462)
Changed printf() calls to fprintf(stderr, ...) for diagnostic messages
in FilesystemUtils and FixedActionPointDistances. This prevents debug
output from contaminating stdout when tools generate structured output
(e.g., JSON config files).

Changes:
- FilesystemUtils: Directory creation/error messages now go to stderr
- FixedActionPointDistances: Thread count info now goes to stderr

This allows tools to cleanly redirect stdout for structured output
while still displaying diagnostic messages on the console.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-11 07:37:38 -07:00
adminandGitHub 63e79b8fae move to common/ (#4456)
* refactor generic mcts stuff into common/

* most tests passing

* more MCTS fixes

* gazelle

* restore missing copts

* one improvement

* dead code
2025-10-10 16:59:40 -07:00
adminandGitHub 5c042dd683 just breakpoint, don't exception when there are no results (#4461) 2025-10-10 16:25:24 -07:00
adminandGitHub f65833fdcb fix a crasher when a battalion is destroyed (#4460) 2025-10-10 16:05:25 -07:00
adminandGitHub a58c13af71 commit pre-commit-config.yaml (#4459) 2025-10-10 16:01:49 -07:00
adminandGitHub 8fe416dc0e Update unity (#4458)
* update Unity to 6000.2.7f2

* unity version
2025-10-10 15:58:59 -07:00
adminandGitHub c74e0506b6 Fix mcts abstraction stubs (#4457)
* get the abstraction layer working

* seems to actually be running now

* remove some logging

* keep the cached commands

* it looks correct

* don't track history, and don't p
ass in the root actions

* fix code review issues
2025-09-30 21:53:17 -07:00
9144d7d7f4 Mcts abstraction (#4455)
* Add abstract MCTS interfaces and Shardok adapters

- Created abstract interfaces for MCTS components:
  - MCTSGameState: Abstract game state with hash, score, and terminal checking
  - MCTSAction: Abstract action/move representation
  - MCTSGameEngine: Abstract game rules and simulation
  - MCTSTypes: Core types (MCTSPlayerId, MCTSConfig, policies)

- Implemented Shardok adapters:
  - ShardokGameState: Wraps GameStateW with MCTS interface
  - ShardokAction: Wraps CommandProto as MCTS action
  - ShardokGameEngine: Adapts ShardokEngine for MCTS
  - ShardokMCTSFactory: Factory for creating adapted components

- Added BUILD.bazel files for new components with proper dependencies

This sets up the foundation for a game-agnostic MCTS implementation
while maintaining compatibility with existing Shardok game logic.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Implement MCTS abstraction layer for game-agnostic AI

- Create abstract interfaces: MCTSGameState, MCTSAction, MCTSGameEngine
- Implement AbstractMCTSAI using only abstract interfaces
- Add Shardok adapters for backward compatibility
- Maintain existing API through ShardokMCTSAI wrapper
- Support multithreaded MCTS with path compression
- Use MCTSPlayerId instead of game-specific PlayerId

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix MCTS abstraction layer build issues

- Fix protobuf field names in ShardokAction.cpp (column vs col)
- Update GameStateW API usage in ShardokGameState.cpp
- Add missing includes and forward declarations
- Update BUILD.bazel files to avoid abseil warnings
- Fix API compatibility issues with IterativeDeepeningAI

Work in progress: Still need to complete adapter implementations

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

Co-Authored-By: Claude <noreply@anthropic.com>

* abstract MCTS does not depend on Shardok game

* partial progress

* Fix MCTS abstraction test failures

- Fix race condition in multithreaded MCTS iteration counter using atomic
- Fix segmentation fault by properly tracking action indices in MCTSNode
- Fix transposition handling test with correct board state comparison
- Fix exploration vs exploitation test with more realistic expectations
- All abstract MCTS tests now pass (11/11 AbstractMCTSAI, 9/9 integration, 10/10 node)

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

Co-Authored-By: Claude <noreply@anthropic.com>

* readme

* simplifications

* optimized clone

* stop on player flip

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-29 19:52:26 -07:00
6aa6b07e61 MCTS path compression (#4453)
* implement brilliant path compression

* path compression tests

* Fix import paths and remove duplicate MCTSNode

- Remove incorrect ai/internal/MCTSNode.hpp (use ai/mcts/internal/ instead)
- Fix relative imports in MCTSAI.cpp to use proper src/main/... paths
- Update BUILD.bazel to remove reference to deleted internal header

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Reorganize MCTS tests into proper mcts subdirectory structure

- Move MCTSAI_test.cpp and MCTSPathCompression_test.cpp to src/test/cpp/net/eagle0/shardok/ai/mcts/
- Create new BUILD.bazel for mcts tests with correct dependencies
- Remove old MCTS test targets from main ai BUILD.bazel
- Fix include paths in test files to use correct mcts paths
- Fix MCTSPathCompression.cpp include path for internal MCTSNode
- Remove duplicate ai_mcts target from main ai BUILD.bazel
- Update visibility permissions for cross-package dependencies
- All MCTS tests now build and pass in their proper location

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

Co-Authored-By: Claude <noreply@anthropic.com>

* gazelle

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-28 20:11:55 -07:00
3802a5bc69 Refactor MCTS: Extract MCTSNode to internal namespace (#4454)
* Refactor MCTS: Extract MCTSNode to internal namespace

Move MCTSNode structure from MCTSAI.cpp to internal/MCTSNode.hpp for
better code organization and testability. This creates a clean
separation between the public MCTS API and internal implementation
details while maintaining full backward compatibility.

Changes:
- Create internal/MCTSNode.hpp with complete MCTSNode definition
- Update MCTSAI.cpp to use internal::MCTSNode via type alias
- Update MCTSAI.hpp forward declarations to use internal namespace
- Update BUILD.bazel to include the new internal header

The MCTSNode structure includes all existing functionality:
- UCB1 calculation and child selection methods
- Iterative destructor for deep tree cleanup
- Transposition detection support

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Create separate Bazel target for internal MCTSNode

Move internal/MCTSNode.hpp to its own Bazel target with restricted
visibility, improving encapsulation and dependency management.

Changes:
- Create internal/BUILD.bazel with mcts_node target
- Restrict visibility to ai and ai test packages only
- Update ai_mcts target to depend on internal:mcts_node
- Remove internal header from ai_mcts hdrs list

This provides better separation of concerns and ensures internal
implementation details are only accessible where needed.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Reorganize MCTS code into dedicated mcts/ package

Move all MCTS-related code into a dedicated package structure for better organization:
- src/main/cpp/net/eagle0/shardok/ai/mcts/
- src/main/cpp/net/eagle0/shardok/ai/mcts/internal/

Changes:
- Create mcts/ package with MCTSAI.cpp/hpp
- Move MCTSNode to mcts/internal/ with restricted visibility
- Update includes and dependencies throughout
- Add mcts package to necessary visibility declarations
- Remove old ai_mcts target from main ai BUILD.bazel
- Update ShardokAIClient to use new mcts package

This provides clean separation of MCTS implementation from other AI algorithms
and establishes proper encapsulation boundaries.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* gazelle

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-28 08:23:41 -07:00
788b8c3338 MCTS only to the end of this player's turn (#4447)
* store the decision tree

* MCTS integration complete

* MCTSAI as a separate target

* still a little drunk but END_TURN is scoring correctly

* END_TURN not marked as terminal

* maybe kinda working

* revert AIScoreCalculator.cpp changes

* log sequence and look for player flip

* coords logging and use the correct gamestate

* didn't do what I hoped

* transposition detection

* Update AI_SCORING_SYSTEM.md with comprehensive MCTS configuration documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Use optimized ShardokEngine constructor with pre-computed critical tiles in MCTS

Eliminates 8.5% runtime overhead by computing critical tiles once and passing them to all
ShardokEngine constructor calls in MCTSAI instead of recomputing them each time.

Updated all relevant locations:
- Search method: compute once at beginning
- BuildMCTSTree: pass through as parameter
- MCTSExpansion: pass through as parameter
- All ShardokEngine(settings, state) calls now use ShardokEngine(settings, state, criticalTiles)

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

Co-Authored-By: Claude <noreply@anthropic.com>

* correct default

* Add null pointer safety checks to prevent MCTS simulation crashes

Added null checks in multiple locations to prevent segmentation faults during MCTS simulation:
- AIScoreCalculator: Check for null units in AttackerUnitsScore loop
- AIScoreCalculator: Check for null attacking unit in RecursiveAttackerMultiplierForTargetDistance
- AIUnitScoreCalculator: Check for null unit at start of UnitValue
- AIAttackGroups: Check for null units in all EffectiveDistance overloads

These crashes were occurring when BEST_IMMEDIATE simulation policy tried to evaluate
game states with invalid or deleted units during MCTS rollouts.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix root cause of MCTS crash: uninitialized memory in Occupants function

The crash was caused by the Occupants function in HexMapUtils.hpp creating a vector
without initializing values. For coordinates without units, the vector contained
garbage values (random memory addresses) rather than nullptr, causing segmentation
faults when dereferenced.

Fixed by initializing both Occupants overloads with nullptr:
  vector<const Unit *> positions(rowCount * columnCount, nullptr);

Removed the band-aid null checks added in the previous commit as they're no longer
necessary with the proper fix in place.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* remove cache eviction

* unnecessary changes

* unnecessary call

* remove some options

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-27 18:20:09 -07:00
fe65d64251 Optimize ActionPointDistancesCache hash lookups and memory usage (#4452)
* Fix use-after-free bug in ActionPointDistancesCache thread-local eviction

The thread-local cache eviction logic in GetRaw() was freeing cache entries
while raw pointers to those entries could still be in use, causing
use-after-free crashes during MCTS simulation.

The eviction was triggered when the cache exceeded 100 entries, which
happened frequently during MCTS due to rapid engine copying and diverse
game state evaluations. The freed memory would then be accessed when
distance calculations tried to use the raw pointers.

This removes the unsafe eviction logic entirely. Memory growth is already
controlled by ConsolidateThreadLocalCache_Racy() which is called after
each AI decision to clear the thread-local cache and move entries to the
persistent cache.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Optimize ActionPointDistancesCache hash lookups and memory usage

Performance improvements:
1. Replace double hash lookups with single find() calls
   - persistentCache.contains() + at() → single find()
   - tlsCache.contains() + at() → single find()
   - Eliminates redundant hash computations

2. Remove redundant rawPtr storage in CacheEntry
   - rawPtr was just storing sharedPtr.get()
   - Now computed on demand, saving 8 bytes per cache entry
   - Reduces memory footprint without performance impact

These changes improve cache performance by reducing hash operations
and memory usage while maintaining the same API and behavior.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-27 15:57:38 -07:00
5e668cb203 Fix use-after-free bug in ActionPointDistancesCache thread-local eviction (#4451)
The thread-local cache eviction logic in GetRaw() was freeing cache entries
while raw pointers to those entries could still be in use, causing
use-after-free crashes during MCTS simulation.

The eviction was triggered when the cache exceeded 100 entries, which
happened frequently during MCTS due to rapid engine copying and diverse
game state evaluations. The freed memory would then be accessed when
distance calculations tried to use the raw pointers.

This removes the unsafe eviction logic entirely. Memory growth is already
controlled by ConsolidateThreadLocalCache_Racy() which is called after
each AI decision to clear the thread-local cache and move entries to the
persistent cache.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-27 14:46:11 -07:00
adminandGitHub eceaeb7550 fix troop count with dismissed units (#4449) 2025-09-27 07:24:58 -07:00
15be1d56a7 Add ShardokEngine constructor with pre-computed critical tile coords (#4448)
Optimization to avoid recomputing critical tiles in MCTS AI, reducing 8.5% runtime overhead.
The new constructor takes criticalTileCoords as a parameter instead of computing them from hex_map.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 18:09:39 -07:00
adminandGitHub 1a757becfb commit pre-commit-config.yaml (#4445) 2025-09-24 08:21:57 -07:00
adminandGitHub 39740f4211 more scalafmt (#4444)
* more scalafmt

* more scalafmt improvements
2025-09-24 08:13:22 -07:00
adminandGitHub 06ba7c2680 Sort Scala imports (#4443)
* sort imports

* rules
2025-09-24 07:16:02 -07:00
7e36c586f0 RequestBattlesAction goes protoless (#4440)
* RequestBattlesAction is protoless

* fix the tests

* Make RequestBattlesAction fully protoless and improve hash stability

- Convert RequestBattlesAction to use protoless model parameters instead of GameState
- Create BattalionUtils for protoless food consumption calculations
- Update RoundPhaseAdvancer to convert proto fields before calling action
- Restore all original test cases using model objects (BattalionC, FactionC, etc.)
- Replace asInstanceOf with inside() pattern matching in tests
- Improve battleHash function to use stable semantic properties instead of toString
- Hash now includes army routing, timing, and faction info for collision resistance

All tests pass with comprehensive protoless functionality.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-22 10:50:32 -07:00
58ab5b77a2 Improve battle hash stability in RequestBattlesAction (#4441)
Replace fragile toString-based hash with stable semantic properties:
- Use army routing information (origin -> destination)
- Include arrival timing and faction IDs
- Sort armies for deterministic ordering
- Base hash on observable properties rather than object representations

This prevents hash changes when object implementations change while
maintaining collision resistance through semantic battle identity.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-22 09:59:34 -07:00
adminandGitHub 85e0a7a8c2 Add newBattle to ActionResultT (#4439)
* Add newBattle to ActionResultT and test for it in PerformUncontestedConquestActionTest

* gazelle
2025-09-21 21:51:30 -07:00
fa5b3d2db9 Make PerformUncontestedConquestAction completely protoless (#4438)
* Make PerformUncontestedConquestAction completely protoless

- Converted PerformUncontestedConquestAction from GameState proto parameter to individual protoless parameters
- Updated constructor to take gameId, currentRoundId, currentDate, provinces, factions, heroes, battalions directly
- Replaced proto types with model types (ProvinceT, FactionT, HeroT, BattalionT)
- Added helper method areMutuallyAllied to replace LegacyFactionUtils dependency
- Updated RoundPhaseAdvancer to call protoless version with proper conversions
- Converted test to use model objects directly instead of proto objects
- Updated BUILD.bazel dependencies to remove proto converters and add model dependencies

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix compilation error in RoundPhaseAdvancer

- Added missing import for BattalionT trait
- Added battalion dependency to BUILD.bazel
- Fixed tuple syntax for battalion mapping
- RoundPhaseAdvancer now compiles successfully

* Make PerformUncontestedConquestAction completely protoless

- Converted action constructor from GameState parameter to individual protoless parameters (gameId, currentRoundId, currentDate, provinces, factions, heroes, battalions)
- Updated RoundPhaseAdvancer to call protoless version with proper type conversions
- Fixed truce faction logic: truce factions now properly bounce with WithdrawalForTruceResultType instead of throwing exception
- Added areMutuallyTruced helper method for handling truce relationships
- Updated test to use model objects directly instead of proto objects
- Removed unused proto dependencies from BUILD files
- All tests pass and server builds successfully

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix faction ID consistency in truce test

- Fixed CombatUnit faction IDs to match their respective army factions
- Faction 1's units now have factionId = 1, faction 2's units have factionId = 2
- Created separate faction2CombatUnits for the truce test instead of reusing shared moreAttackerCombatUnits
- Addresses Copilot feedback about inconsistent test data

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-21 19:07:50 -07:00
e74e0d6190 Make ProvinceConqueredAction completely protoless (#4437)
* Make ProvinceConqueredAction completely protoless

- Replace protobuf CombatUnit import with model CombatUnit
- Remove unused protobuf and converter imports
- Update BUILD.bazel to remove unused dependencies
- All tests still pass

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix PerformUncontestedConquestAction

* cleanup

* unneeded imports

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-21 17:43:20 -07:00
adminandGitHub 0e31df16b9 oops (#4436) 2025-09-20 21:58:00 -07:00
adminandGitHub 7b0518f1c7 ransom invalidation not registering in time (#4435)
* ransom invalidation not registering in time

* cleanup & run gazelle

* reorder

* more reorder

* more cleanup

* better modularity

* cleanup
2025-09-20 19:38:49 -07:00
adminandGitHub deedc5341e color trade/gold red if over cap (#4433) 2025-09-19 17:16:14 -07:00
4bbecdc73c Add comprehensive withdrawn units test for protobuf version (#4432)
- Added test 'should create incoming armies in destination provinces for withdrawn units with explicit flee provinces'
- Tests fled attackers with explicit flee provinces are properly converted to incoming armies
- Verifies all MovingArmy properties are correctly set in protobuf version
- Complements existing fled defenders and fled attackers tests
- All 25 tests pass including new withdrawn units validation test

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 12:04:06 -07:00
2bb2066679 Make FreeForAllDrawAction completely protoless (#4430)
* WIP: Convert FreeForAllDrawAction to protoless interface

- Changed constructor to accept model types instead of protobuf
- Updated implementation to work with MovingArmy model objects
- Removed protobuf dependencies from imports and BUILD file
- Scalafmt formatting applied
- Ready for rebase on main to get updated call sites

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete FreeForAllDrawAction protoless conversion

- Updated ResolveBattleAction call site to use new protoless interface
- Converted parameters: defenderProvince, armiesFromPlayers, remainingUnits
- Removed protobuf dependencies from FreeForAllDrawAction completely
- Server builds successfully after rebase on main
- Action now uses model types instead of protobuf types
- Scalafmt formatting applied

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 17:28:34 -07:00
c91bf673d0 Make WonFreeForAllAction completely protoless (#4429)
* Make WonFreeForAllAction completely protoless

- Convert WonFreeForAllAction from proto GameState + Province to individual model types
- Change parameters: battalions Map, battleProvince ProvinceT, winningArmyGroups Vector[HostileArmyGroup]
- Update ResolveBattleAction call site to convert proto types to model types using converters
- Update all test cases to use new interface with proper type conversions
- Remove dependency on protobuf shardok_battle types
- All tests pass and server builds successfully

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Make WonFreeForAllActionTest truly protoless

- Replace all protobuf objects with Scala model objects in test
- Remove protobuf dependencies from test BUILD.bazel
- Create MovingArmy, HostileArmyGroup, and other model objects directly
- Remove proto converter calls and proto matchers
- Test now uses only model types, no protobuf conversion

Note: Test has compilation issues with ID types that need to be resolved

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix WonFreeForAllAction test compilation issues (partial)

- Updated MovingArmy and battalion ID usage to use raw Int values
- Fixed some type mismatches in test data construction
- Note: Test still has compilation issues with BattalionTypeId and CanEqual imports

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix the test

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 17:08:20 -07:00
adminandGitHub 410ff0c50c add river crossing info to March command (#4428)
* add river crossing info to March command

* AI uses what's in the command

* fix the test

* display river crossing info

* water crossing bug
2025-09-18 10:52:45 -07:00
955bb1db8a Make battle results actions (PerformUnconquestedConquestAction, ProvinceConqueredAction, ProvinceHeldAction, ResolveBattleAction) and RequestBattlesAction protoless (#4421)
* claude doing its thing

* ProvinceConqueredAction

* no really, go protoless

* fix one

* more unrelated changes

* cleanup

* bad change

* wat

* make more actions protoless

* two more tests

* remove duplicates

* last test

* correct sorting

* fix gender conversion bug and more protoless

* fix tests

* update the .md file

* fix ProvinceConqueredAction sorting

* Fix ResolveBattleAction battalion handling

Use battalion directly from ResolvedEagleUnit instead of looking up in startingState.
This fixes type mismatch between BattalionT and internal Battalion proto.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 22:16:35 -07:00
adminandGitHub ea0f23de7a Protoless interface for ResolvedEagleUnit (#4425)
* convert ResolvedEagleUnit to protoless

* gazelle

* unit status

* rename

* move protobuf out of ResolvedEagleUnit entirely

* more protoless

* more deprotoification

* more deprotoification
2025-09-17 14:27:03 -07:00
adminandGitHub 376680e4c7 sortOrdering (#4427) 2025-09-17 14:21:59 -07:00
adminandGitHub 010649b4cc UnitStatus scala model (#4426)
* UnitStatus and converter

* use the new UnitStatus in EventForHeroBackstoryT
2025-09-17 14:02:58 -07:00
adminandGitHub c90f8e0f11 add fields to RequestBattlesActionTest heroes (#4423) 2025-09-17 07:26:52 -07:00
833 changed files with 33205 additions and 23185 deletions
+2 -2
View File
@@ -19,9 +19,9 @@ common --worker_sandboxing
common --local_test_jobs=64
common --jobs=64
common --cxxopt="--std=c++20"
common --cxxopt="--std=c++23"
common --cxxopt="-Wno-deprecated-non-prototype"
common --host_cxxopt="--std=c++20"
common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
+3
View File
@@ -0,0 +1,3 @@
CompileFlags:
Add:
- "-std=c++23"
+1 -2
View File
@@ -20,7 +20,7 @@ project/boot/
project/plugins/project/
project/target/
bazel-bin
bazel-eagle0
bazel-eagle0*
bazel-out
bazel-testlogs
.ijwb
@@ -32,7 +32,6 @@ buildWin.sh
__pycache__/
scripts/refresh_name_layers/vendor/
scripts/refresh_name_layers/refresh_name_layers.zip
.pre-commit-config.yaml
.bazelbsp
.bsp
.metals
+43
View File
@@ -0,0 +1,43 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-added-large-files
- id: no-commit-to-branch
args: [--branch, main]
- repo: https://github.com/pocc/pre-commit-hooks
rev: v1.3.5
hooks:
- id: clang-format
args: [-i, --no-diff]
types_or: ["c++", "c#"]
exclude: ^src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins
- repo: https://github.com/yoheimuta/protolint
rev: v0.42.2
hooks:
- id: protolint
args: [-fix]
exclude: ^src/main/protobuf/scalapb/
- repo: local
hooks:
- id: scalafmt
name: scalafmt
language: system
entry: scalafmt -i -f
types_or: ["scala"]
- repo: local
hooks:
- id: gazelle
name: gazelle
language: system
entry: bazel run //:gazelle
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
- repo: local
hooks:
- id: update-action-result-types
name: update-action-result-types
language: system
entry: ./scripts/updateActionResultTypes.sh
files: 'src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto'
+41
View File
@@ -1,6 +1,47 @@
version = "3.9.9"
runner.dialect = scala3
rewrite.scala3.convertToNewSyntax = true
# Keep braces, don't use significant indentation
# rewrite.scala3.removeOptionalBraces = yes
rewrite.scala3.insertEndMarkerMinLines = 15
rewrite.scala3.removeEndMarkerMaxLines = 14
# Strip margin settings
assumeStandardLibraryStripMargin = false
align.stripMargin = true
# Code Style & Formatting
align.preset = more
align.multiline = true
align.arrowEnumeratorGenerator = true
spaces.inImportCurlyBraces = false
spaces.beforeContextBoundColon = Never
maxColumn = 120
docstrings.style = Asterisk
docstrings.wrap = yes
# Method chaining
newlines.beforeCurlyLambdaParams = multilineWithCaseOnly
optIn.breakChainOnFirstMethodDot = true
includeCurlyBraceInSelectChains = false
# Advanced Scala 3 Features
rewrite.scala3.countEndMarkerLines = all
rewrite.redundantBraces.stringInterpolation = true
rewrite.redundantBraces.parensForOneLineApply = true
# Project-Specific Considerations
optIn.annotationNewlines = true
runner.optimizer.forceConfigStyleMinArgCount = 3
# Import sorting configuration
rewrite.rules = [SortImports, RedundantBraces, RedundantParens]
rewrite.imports.sort = scalastyle
rewrite.imports.groups = [
["java\\..*"],
["javax\\..*"],
["scala\\..*"],
[".*"]
]
rewrite.imports.contiguousGroups = only
rewrite.trailingCommas.style = never
+82 -6
View File
@@ -4,26 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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.
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
- **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
@@ -31,13 +37,17 @@ Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
## 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)
# 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
@@ -46,6 +56,7 @@ bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
```
### Running Services
```bash
# Eagle server (port 40032)
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
@@ -57,6 +68,7 @@ bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=op
```
### Testing
```bash
# Run all tests
bazel test //src/test/... //src/main/go/...
@@ -67,12 +79,14 @@ 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
```
### Code Formatting
```bash
# ALWAYS run clang-format after making any C++ or C# code changes
clang-format -i <modified_files>
@@ -85,35 +99,94 @@ 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++20
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++20
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.0.32f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
@@ -122,6 +195,7 @@ bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,cla
- Seamless transition between strategic gameplay and hex-based tactical combat
**Go (Build Tools):**
- Build automation and code generation utilities
- AWS S3 integration for deployment artifacts
@@ -163,10 +237,12 @@ done
```
**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.
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
behavior changes.
## Game Content
+463
View File
@@ -0,0 +1,463 @@
# MCTS Transposition Table Enhancement: Caching State Transitions
## Executive Summary
**Goal:** Avoid redundant `PostCommand` calls by caching state transitions (stateHash, actionIndex, roll) → nextStateHash.
**Key Findings:**
1. ✅ MCTS already has thread-local cache (`legalActionsCache_`) with 70-90% hit rates
2.**DETERMINISM SOLUTION:** Cache (action, roll) tuples instead of just action
- Initial implementation: always use roll=50
- Future-proof: supports chance nodes with multiple rolls (10, 30, 50, 70, 90)
- Architecturally solves the non-determinism problem
3. ✅ Enhancement is straightforward: add `actionResults` map to existing `LegalActionsCache` struct
4. ✅ Expected benefit: Eliminate PostCommand overhead on cache hits (could save 5-15 microseconds per hit)
**Recommendation:** Implement using (action, roll) tuple keys. Initial implementation uses roll=50 for all transitions, but data structure supports future expansion to chance nodes.
---
## Current Implementation
**IMPORTANT:** MCTS does NOT use the TranspositionTable.hpp/cpp files. Those are for IterativeDeepening AI.
MCTS uses a thread-local cache in `ShardokGameEngine` (src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp):
```cpp
// Thread-local cache (line 20-21)
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache> legalActionsCache_;
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine; // Cached engine with populated command cache
std::vector<size_t> filteredIndices; // Filtered action indices
};
```
**Current cache mapping:**
```
stateHash -> LegalActionsCache {
ShardokEngine engine, // Engine with command cache populated
vector<size_t> filteredIndices // Filtered action indices
}
```
**Purpose:** Avoid recalculating GetAvailableCommands and FilterCommands for states we've seen before.
**Performance:** Already achieving ~70-90% hit rates in typical searches (see reportCacheStatistics() output).
## Proposed Enhancement
Extend the `LegalActionsCache` struct to also cache state transitions using (action, roll) tuples:
```cpp
// Key for transition cache: (actionIndex, roll)
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine; // Existing: cached engine
std::vector<size_t> filteredIndices; // Existing: filtered action indices
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW: (action, roll) -> next_state_hash
};
```
**Purpose:** Avoid re-applying actions (PostCommand calls) for (state, action, roll) tuples we've already evaluated.
**Why (action, roll) tuples?**
- Handles determinism explicitly: different rolls produce different next states
- Initial implementation uses roll=50 for all transitions
- Future-proof: supports chance nodes with multiple roll values (10, 30, 50, 70, 90)
- Architecturally cleaner than requiring PostCommand to always use the same roll
**Location:** Modify `ShardokGameEngine::applyAction()` (line 50-99 in ShardokGameEngine.cpp)
## How It Works
### During MCTS Exploration/Simulation:
1. **Before applying an action:**
- Look up current state hash in cache
- If found, check if `actionResults` contains the (action_index, roll) tuple we want to apply
- If yes, retrieve the next state's hash from the map
- Look up that hash in the cache to get the next state's engine directly
- **Skip PostCommand entirely** - we already know the result!
2. **When applying a new action:**
- Apply action normally via `engine.PostCommand(playerId, actionIndex, roll)`
- Hash the resulting state
- Store the mapping: `actionResults[{action_index, roll}] = next_state_hash`
- Store the next state in the cache (if not already present)
### Example Flow:
```cpp
// Current state hash: 0xABCD
// Want to apply action index 5 with roll 50
const int roll = 50; // Fixed roll for initial implementation
auto it = legalActionsCache_.find(0xABCD);
if (it != legalActionsCache_.end()) {
TransitionKey key{5, roll};
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
// We've applied this (action, roll) before!
uint64_t nextHash = resIt->second;
if (auto nextIt = legalActionsCache_.find(nextHash); nextIt != legalActionsCache_.end()) {
// We have the complete next state cached
// Clone the cached engine and return - No PostCommand needed!
return createStateFromCachedEngine(nextIt->second.engine);
}
}
}
// Haven't seen this (state, action, roll) tuple before, apply normally
engine.PostCommand(playerId, 5, roll);
uint64_t nextHash = HashGameState(engine.GetCurrentGameState());
legalActionsCache_[0xABCD].actionResults[{5, roll}] = nextHash;
```
## Benefits
1. **Eliminates redundant PostCommand calls**
- PostCommand involves creating new GameStateW, potentially allocating memory
- Combat resolution, unit updates, state validation all skipped when cached
2. **Particularly valuable for MCTS**
- MCTS revisits states many times during tree search
- Same state-action pairs explored in multiple simulations
- Deeper trees mean more opportunities for cache hits
3. **Compounds with existing optimizations**
- Already caching score calculations
- Already caching available commands
- Now also caching state transitions
- All three together significantly reduce per-simulation cost
## Potential Issues & Solutions
### 1. Memory Usage
**Issue:** Storing (action, roll)->hash mappings for every visited state could consume significant memory.
**Mitigation:**
- Only store recently used entries (already done - thread-local cache per search)
- Cache is automatically cleared between searches
- Monitor memory usage in production
**Analysis:**
- Each cache entry: `TransitionKey{size_t actionIndex, int roll}` + `uint64_t nextHash`
- Size: ~24 bytes per entry (8 + 4 + 8, plus hash map overhead)
- For 10,000 states × 10 actions × 1 roll = 100,000 entries ≈ 2.4 MB
- With chance nodes (5 rolls per action): 10,000 × 10 × 5 = 500,000 entries ≈ 12 MB
- **Reasonable** for modern systems, especially since it's thread-local and cleared per search
### 2. Determinism Requirements
**Issue:** PostCommand results depend on the roll parameter, which affects combat outcomes and random events.
**ARCHITECTURAL SOLUTION:** Cache (action, roll) tuples instead of just actions!
```cpp
// Cache key includes BOTH action and roll
TransitionKey key{actionIndex, roll};
actionResults[key] = nextStateHash;
```
**Why this solves the problem:**
- Each (action, roll) combination gets its own cache entry
- If we call PostCommand(5, 50), we cache the result for (5, 50)
- If we later call PostCommand(5, 70), it's a different cache key - no collision!
- No need to enforce determinism at the PostCommand level
- Data structure naturally supports multiple rolls per action
**Initial Implementation:**
- Use fixed roll=50 for all transitions (matching IterativeDeepening)
- All cache entries will have roll=50
- Simple and deterministic
**Future Enhancement:**
- Implement chance nodes by exploring multiple rolls (10, 30, 50, 70, 90)
- Each roll becomes a separate child in the MCTS tree
- Cache naturally handles this: (action=5, roll=10), (action=5, roll=50), (action=5, roll=90) are distinct
- This models uncertainty without requiring code changes to the cache structure
**Required Changes:**
1. **Change PostCommand call in `applyAction()` (line 82):**
```cpp
// OLD:
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
// NEW:
const int roll = 50; // Fixed roll for initial implementation
engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
```
2. **Change PostCommand call in `applyActionMutable()` (line 133):**
```cpp
// Same change - use roll=50
```
3. **Store the roll used when caching:**
```cpp
TransitionKey key{actionIndex, roll};
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
```
**Status:** ✅ SOLVED ARCHITECTURALLY - No determinism issues with this design.
### 3. Hash Collisions
**Issue:** Two different states might hash to the same value.
**Current situation:** Already a risk with existing transposition table.
**Mitigation:**
- Use 64-bit hashes (current implementation) - collision probability very low
- Could add verification: store state size/checksum alongside hash
- Could add debug mode that does full state comparison
### 4. Action Index Stability
**Issue:** Action indices must be stable (same action always has same index for a given state).
**Verification:**
- ShardokEngine::GetAvailableCommandProtos() must return actions in deterministic order
- Need to verify this is true
- If not, would need to hash actions themselves, not just use indices
**Risk:** LOW - game engine likely returns actions in consistent order
### 5. State Ownership & Copying
**Issue:** GameStateW contains FlatBufferBuilder, careful with copying/references.
**Solution:**
- TranspositionTable already stores complete ShardokEngine (which contains GameStateW)
- No additional complexity beyond existing implementation
- Just need to ensure we're cloning engines appropriately
## Implementation Plan
### Phase 1: Define TransitionKey and Extend Data Structure
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`
1. **Add TransitionKey struct (before LegalActionsCache):**
```cpp
// Key for transition cache: (actionIndex, roll)
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
```
2. **Update `LegalActionsCache` struct:**
```cpp
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine;
std::vector<size_t> filteredIndices;
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW
};
```
3. **Add cache statistics fields:**
```cpp
// Add to class members:
thread_local static uint64_t transitionCacheHits_;
thread_local static uint64_t transitionCacheMisses_;
```
### Phase 2: Integrate with applyAction
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`
Modify `ShardokGameEngine::applyAction()` (lines 50-99):
```cpp
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
const MCTSGameState& state,
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) { return nullptr; }
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for initial implementation
// NEW: Check if we've already applied this (action, roll) to this state
TransitionKey key{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
// We have the next state hash cached!
uint64_t nextStateHash = resIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash); nextIt != legalActionsCache_.end()) {
// CACHE HIT: Clone the cached engine and return the state
transitionCacheHits_++;
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
return std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
gameSettings_.get(),
isDefender_,
strategy_,
castleCoords_,
*apdCache_,
*alCache_,
criticalTileCoords_);
}
}
}
// CACHE MISS: Apply action normally...
transitionCacheMisses_++;
// (existing code from lines 58-98, but change line 82:)
// OLD: engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
// NEW: engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
// After applying, store the transition:
uint64_t nextStateHash = newState->hash();
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
return newState;
}
```
**Similar changes for `applyActionMutable()`** (lines 100-150)
### Phase 3: Testing & Validation (Critical)
1. Add unit tests verifying:
- Cached transitions match actual transitions
- Performance improvement measurable
- No correctness regressions
2. Add debug assertions:
- Verify determinism: cached result == recomputed result (sample check)
- Detect hash collisions (optional, performance cost)
3. Add performance tracking:
- Count cache hits vs misses
- Measure time saved
- Monitor memory usage
### Phase 4: Optimization (Optional)
1. Tune cache eviction policy if memory becomes issue
2. Consider bloom filter to quickly reject cache misses
3. Profile to ensure cache lookups aren't dominating cost
## Performance Expectations
**Conservative estimate:**
- PostCommand might take ~10-50 microseconds (allocations, state updates)
- Hash table lookup takes ~100-500 nanoseconds
- If we get 30% cache hit rate, save ~3-15 microseconds per simulation step
- With 100,000 simulations, save 0.3-1.5 seconds per search
**Best case estimate:**
- In dense search trees, might get 70%+ cache hit rate
- Could save 5-10 seconds per search in complex positions
**Measurement needed:** Profile actual PostCommand cost and cache hit rates.
## Risks
**HIGH RISK:**
- Non-determinism in PostCommand would cause silent correctness bugs
- Must thoroughly test determinism before enabling in production
**MEDIUM RISK:**
- Memory usage could grow large in long-running games
- Need monitoring and eviction policy
**LOW RISK:**
- Implementation complexity moderate but manageable
- Can be feature-flagged and disabled if problems arise
## Recommendation
**This optimization appears sound IF PostCommand is deterministic.**
**Suggested approach:**
1. First, verify PostCommand determinism with extensive testing
2. Implement with feature flag (can disable if issues found)
3. Add comprehensive debug assertions
4. Profile to verify performance improvement justifies complexity
5. Monitor memory usage in production
**Key verification needed before proceeding:**
- Confirm PostCommand has no randomness
- Confirm action indices are stable
- Measure baseline PostCommand performance cost
## Alternative Considered: Lighter-Weight Caching
Instead of storing full state transitions, just cache:
```cpp
unordered_map<pair<uint64_t, size_t>, uint64_t> globalTransitionCache;
// Maps (state_hash, action_index) -> next_state_hash
```
**Pros:**
- Simpler data structure
- Easier to implement cache eviction
**Cons:**
- Requires two hash lookups per transition (this map, then transposition table)
- Doesn't integrate as cleanly with existing transposition table
**Verdict:** Proposed approach (extending LegalActionsCache) is cleaner and integrates with existing infrastructure.
## Files to Modify
### Phase 1: Data Structure
1. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`**
- Add `TransitionKey` struct with equality operator
- Add `TransitionKeyHash` struct for hashing
- Modify `LegalActionsCache` to use `gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;`
- Add thread-local statistics: `transitionCacheHits_`, `transitionCacheMisses_`
### Phase 2: Implementation
2. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`**
- Initialize thread-local statistics variables
- Modify `applyAction()`:
- Line 82: Change `nullptr` to `50` for roll parameter
- Add transition cache lookup using `TransitionKey{actionIndex, 50}`
- Add transition cache storage after PostCommand
- Modify `applyActionMutable()`:
- Line 133: Change `nullptr` to `50` for roll parameter
- Add same transition cache logic
- Update `reportCacheStatistics()` to include transition cache hit/miss stats
### Phase 3: Testing
3. **`src/test/cpp/net/eagle0/shardok/ai/mcts/ShardokMCTSAI_test.cpp`** (or create new test file)
- Add unit tests for determinism verification (same search → same result)
- Add unit tests for transition cache correctness
- Add performance benchmarks comparing with/without transition cache
- Test with multiple rolls to verify tuple caching works correctly
## Related Work
This optimization is related to:
- **Zobrist hashing** in chess engines (incremental hash updates)
- **Transposition tables with move ordering** in minimax search
- **Memoization** in dynamic programming
Shardok's approach is most similar to transposition tables in chess engines, but applied to MCTS instead of minimax.
+22 -10
View File
@@ -9,15 +9,16 @@ This document analyzes all actions and commands in `src/main/scala/net/eagle0/ea
## Summary
Based on BUILD.bazel dependency analysis (2025-09-16):
Based on BUILD.bazel dependency analysis (2025-09-16, updated 2025-09-17):
- **Total Commands Analyzed:** 41
- **Commands Fully Migrated (No Protobuf):** 41 (100%) ✅
- **Commands Still Using Protobuf:** 0 (0%) ✅
- **Total Actions Analyzed:** 48
- **Actions Fully Migrated (No Protobuf):** 4 (8.3%)
- **Actions Partially Migrated:** 20 (41.7%)
- **Actions Fully Migrated (No Protobuf):** 5 (10.4%)
- **Actions Partially Migrated:** 19 (39.6%)
- **Actions Still Using Protobuf:** 24 (50%)
- **Base Classes:** 8 protoless variants available, 6 still use protobuf
- **Shared Components:** `ResolvedEagleUnit` migrated to use `Option[BattalionT]` for proper null handling
## Conversion Insights
@@ -93,6 +94,7 @@ These actions have been successfully migrated to use Scala models only:
| File | Base Class | Notes |
|------|------------|-------|
| HeroBackstoryUpdateAction.scala | ProtolessSequentialResultsAction | Processes hero backstory updates with LLM integration |
| ProvinceConqueredAction.scala | ProtolessSimpleAction | Uses component-based design (gameId, currentRoundId, currentDate, Scala models) |
| ProvinceHeldAction.scala | ProtolessSimpleAction | Uses specific components (gameId, currentRoundId, defendingProvince, etc.) instead of full GameState |
| UnaffiliatedHeroAppearedAction.scala | ProtolessSimpleAction | Handles unaffiliated hero appearance with name generation |
| WithdrawnArmiesReturnHomeAction.scala | ProtolessSequentialResultsAction | Manages army withdrawal and return mechanics |
@@ -115,7 +117,7 @@ These actions use protoless base classes but still have some protobuf dependenci
| FreeForAllDrawAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| FriendlyMoveAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| PerformUncontestedConquestAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| ProvinceConqueredAction.scala | ProtolessSimpleAction | Depends on `common_unit_scala_proto` |
| ProvinceConqueredAction.scala | ProtolessSimpleAction | **CONVERTED** - Uses specific components (gameId, currentRoundId, currentDate, Scala models) |
| SafePassageArmiesProceedAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| ShipmentArrivedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| TruceTurnBackPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
@@ -246,21 +248,30 @@ Based on the BUILD.bazel dependency analysis, here are the key findings and reco
### 🎯 Action Migration Progress
**Migration Statistics:**
- 4/48 Actions fully migrated (8.3%)
- 5/48 Actions fully migrated (10.4%)
- 20/48 Actions using protoless base classes but with protobuf dependencies (41.7%)
- 24/48 Actions still fully on protobuf (50%)
**Successfully Migrated Actions:**
1. **HeroBackstoryUpdateAction** - LLM integration for hero backstories
2. **ProvinceHeldAction** - Component-based design pattern (gameId, currentRoundId, specific models)
3. **UnaffiliatedHeroAppearedAction** - Hero appearance with name generation
4. **WithdrawnArmiesReturnHomeAction** - Army withdrawal mechanics
2. **ProvinceConqueredAction** - Component-based design with prisoner handling and province conquest
3. **ProvinceHeldAction** - Component-based design pattern (gameId, currentRoundId, specific models)
4. **UnaffiliatedHeroAppearedAction** - Hero appearance with name generation
5. **WithdrawnArmiesReturnHomeAction** - Army withdrawal mechanics
**Recent Migration Updates (2025-09-17):**
- **ResolvedEagleUnit** - Changed `battalion: BattalionT` to `battalion: Option[BattalionT]`
- Properly handles units without battalions (battalion ID -1)
- Updated `ShardokInterfaceGrpcClient` to check for `defaultBattalionId` and use `None`
- Updated `ResolveBattleAction`, `ProvinceConqueredAction`, `RequestBattlesAction`
- All tests updated to handle optional battalions
**Key Migration Patterns:**
- ✅ Use specific components instead of full GameState (see ProvinceHeldAction)
- ✅ Use specific components instead of full GameState (see ProvinceHeldAction, ProvinceConqueredAction)
- ✅ Convert protobuf models to Scala models at Action boundaries
- ✅ Update BUILD.bazel to remove protobuf dependencies
- ✅ Update all call sites and tests
- ✅ Use `Option[T]` for optional fields instead of special sentinel values (e.g., battalion ID -1)
**Next Migration Candidates (Simple Actions with Protoless Base):**
1. **FreeForAllDrawAction** - Already uses ProtolessSimpleAction
@@ -290,4 +301,5 @@ Based on the BUILD.bazel dependency analysis, here are the key findings and reco
---
*Updated on 2025-09-16 - Analysis based on BUILD.bazel dependencies and code review*
*Updated on 2025-09-17 - Analysis based on BUILD.bazel dependencies and code review*
*Latest update: ResolvedEagleUnit migrated to use Option[BattalionT] for proper battalion handling*
+1 -1
View File
@@ -1,2 +1,2 @@
UNITY_VERSION='6000.1.11f1'
UNITY_VERSION='6000.2.7f2'
+22 -2
View File
@@ -18,11 +18,31 @@ static inline auto MixIn(uint64_t& hash, const uint8_t byte) {
}
// Hash an entire buffer using FNV-1a
// Fast word-at-a-time implementation - processes 8 bytes at once for better performance
// while maintaining good distribution properties for hash table use
static inline auto HashBuffer(const uint8_t* data, size_t size) -> uint64_t {
if (data == nullptr) { return FNV_OFFSET_BASIS; }
uint64_t hash = FNV_OFFSET_BASIS;
if (data != nullptr) {
for (size_t i = 0; i < size; ++i) { MixIn(hash, data[i]); }
const uint8_t* end = data + size;
// Process 8 bytes at a time
while (data + 8 <= end) {
uint64_t word;
// Use memcpy to avoid alignment issues and let compiler optimize
__builtin_memcpy(&word, data, sizeof(word));
hash ^= word;
hash *= FNV_PRIME;
data += 8;
}
// Process remaining bytes
while (data < end) {
hash ^= static_cast<uint64_t>(*data);
hash *= FNV_PRIME;
data++;
}
return hash;
}
@@ -30,7 +30,7 @@ auto rloc(const string& execPath) -> string {
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
if (runfiles == nullptr) {
printf("Error! %s\n", error.c_str());
fprintf(stderr, "Error! %s\n", error.c_str());
abort();
// error handling
}
@@ -67,9 +67,9 @@ auto FilesystemUtils::MapFilesDirectory() -> string {
void FilesystemUtils::MakeDirectoryIfNecessary(const string& directoryPath) {
if (fs::create_directories(directoryPath))
printf("Directory %s created\n", directoryPath.c_str());
fprintf(stderr, "Directory %s created\n", directoryPath.c_str());
else
printf("No new directory created for %s\n", directoryPath.c_str());
fprintf(stderr, "No new directory created for %s\n", directoryPath.c_str());
}
auto FilesystemUtils::SaveFilesDirectory() -> string {
@@ -129,11 +129,11 @@ auto FilesystemUtils::AtomicallySaveToPath(const string& path, const byte_vector
if (ostr.good()) {
const int err = rename(tempPath.c_str(), path.c_str());
if (err == -1) {
printf("Failed to move file to %s! Errno %d\n", path.c_str(), errno);
fprintf(stderr, "Failed to move file to %s! Errno %d\n", path.c_str(), errno);
return false;
}
} else {
printf("Failed writing to %s!\n", tempPath.c_str());
fprintf(stderr, "Failed writing to %s!\n", tempPath.c_str());
return false;
}
@@ -0,0 +1,139 @@
# MCTS (Monte Carlo Tree Search) Framework
This directory contains a game-agnostic Monte Carlo Tree Search implementation that can be used with any turn-based game. The framework separates the MCTS algorithm from game-specific logic through abstract interfaces.
## Core Abstract Classes
### `MCTSAction` (abstract/MCTSAction.hpp)
Abstract interface for representing game actions/moves.
**Key Methods:**
- `getIndex()` - Returns the action's unique identifier
- `getDescription()` - Human-readable description for debugging/logging
- `clone()` - Creates a deep copy of the action
- `equals()` - Compares actions for equality
### `MCTSGameState` (abstract/MCTSGameState.hpp)
Abstract interface for representing game states.
**Key Methods:**
- `hash()` - Returns a hash for transposition table lookups
- `score(playerId)` - Evaluates the state's value for a given player
- `currentPlayerId()` - Returns whose turn it is
- `isTerminal()` - Checks if the game has ended
- `getWinner()` - Returns the winning player (if terminal)
- `clone()` - Creates a deep copy of the state
- `equals()` - Compares states for equality
### `MCTSGameEngine` (abstract/MCTSGameEngine.hpp)
Abstract interface for game rule enforcement and state transitions. Many methods have efficient default implementations.
**Must Override (Pure Virtual):**
- `applyAction(state, action)` - Applies an action to create a new state
- `getLegalActions(state)` - Returns all valid moves from a state
- `isTerminal(state)` - Checks if a state is game-ending
- `evaluateState(state, playerId)` - Scores a state for a player
**Optional Overrides (Have Default Implementations):**
- `applyActionMutable(state, action)` - Apply action in-place for efficiency (default: calls applyAction)
- `filterActions(actions, state)` - Applies heuristic filtering (default: no filtering)
- `simulateRandomPlayout(state, playerId, maxDepth, policy)` - Runs simulation (default: efficient mutable implementation)
- `getActionScore(state, action, playerId)` - Scores an action (default: apply and evaluate)
- `shouldStopSearch(state, iterations, startTime)` - Early termination (default: no early stop)
**Performance Features:**
- The default `simulateRandomPlayout` clones the state once and mutates it throughout simulation for efficiency
- Games can override `applyActionMutable` to provide even more efficient in-place updates
- Games can override `simulateRandomPlayout` for custom optimizations (e.g., using internal engine state)
## MCTS Algorithm Implementation
### `AbstractMCTSAI` (abstract/AbstractMCTSAI.hpp)
The main MCTS algorithm implementation that works with any game implementing the abstract interfaces.
**Key Features:**
- **Selection**: Uses UCB1 (Upper Confidence Bound) for node selection
- **Expansion**: Adds new nodes to the search tree
- **Simulation**: Runs random playouts to estimate node values
- **Backpropagation**: Updates node statistics with simulation results
- **Multithreading**: Supports parallel MCTS with configurable thread count
- **Path Compression**: Optimizes move sequences for better performance
**Configuration Options:**
- `explorationConstant` - UCB1 exploration parameter (default: √2)
- `maxSimulationDepth` - Maximum depth for random playouts
- `maxTreeDepth` - Maximum tree depth to prevent stack overflow
- `useMultithreading` - Enable parallel search
- `numThreads` - Number of worker threads
- `simulationPolicy` - Strategy for action selection during simulation
### `MCTSNode` (abstract/MCTSNode.hpp)
Represents nodes in the MCTS search tree.
**Core Data:**
- `action` - The action that led to this node
- `actionIndex` - Index in the original actions array
- `gameState` - The game state at this node
- `visitCount` - Number of times this node was visited
- `totalReward` - Sum of simulation rewards
- `averageReward` - Average reward (totalReward / visitCount)
- `children` - Child nodes in the search tree
- `parent` - Parent node reference
**Key Methods:**
- `CanExpand()` - Checks if node has untried actions
- `GetBestChild(explorationConstant)` - UCB1-based child selection
- `GetBestFinalChild()` - Most-visited child (for final move selection)
- `CalculateUCB1(explorationConstant)` - Computes UCB1 value
## Simulation Policies
The framework supports multiple strategies for action selection during random playouts:
- **RANDOM** - Uniform random selection
- **FILTERED_RANDOM** - Random selection from filtered action set
- **BEST_IMMEDIATE** - Always choose the highest-scoring immediate action
- **WEIGHTED_BEST_IMMEDIATE** - Weighted random selection based on action scores
## Type Definitions
### `MCTSTypes` (abstract/MCTSTypes.hpp)
- `MCTSPlayerId` - Player identifier type (int)
- `MCTSSimulationPolicy` - Enumeration of simulation strategies
- `MCTSConfig` - Configuration structure for MCTS parameters
## Usage Pattern
To use this framework with your game:
1. **Implement the abstract interfaces** for your game:
```cpp
class MyGameAction : public MCTSAction { /* ... */ };
class MyGameState : public MCTSGameState { /* ... */ };
class MyGameEngine : public MCTSGameEngine { /* ... */ };
```
2. **Create and configure the AI**:
```cpp
MCTSConfig config;
config.explorationConstant = 1.414;
config.maxSimulationDepth = 100;
AbstractMCTSAI ai(playerId, config);
```
3. **Run the search**:
```cpp
auto actions = engine.getLegalActions(currentState);
auto result = ai.Search(engine, currentState, actions, timeLimit);
auto bestAction = actions[result.bestActionIndex];
```
## Testing
The framework includes comprehensive tests using a Tic-Tac-Toe implementation:
- `MockTicTacToe.hpp` - Example implementation of all abstract interfaces
- `AbstractMCTSAI_test.cpp` - Unit tests for the core algorithm
- `MCTSIntegration_test.cpp` - Integration tests with complete games
- `MCTSNode_test.cpp` - Tests for the node data structure
This demonstrates how to implement the interfaces and validates that the MCTS algorithm works correctly with any turn-based game.
@@ -0,0 +1,684 @@
//
// Abstract MCTS AI implementation
//
#include "AbstractMCTSAI.hpp"
#include <algorithm>
#include <future>
#include <mutex>
#include <random>
#include <stdexcept>
#include <thread>
namespace shardok::mcts {
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
: playerId_(playerId),
config_(config) {}
auto AbstractMCTSAI::Search(
const MCTSGameEngine& engine,
const MCTSGameState& initialState,
std::chrono::milliseconds timeLimit) const -> SearchResult {
const auto startTime = std::chrono::steady_clock::now();
const auto deadline = startTime + timeLimit;
// Build MCTS tree
const auto rootNode = BuildMCTSTree(engine, initialState, deadline);
SearchResult result;
result.searchTime = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime);
if (!rootNode) {
throw MCTSInternalError("MCTS search: BuildMCTSTree returned null root node");
}
if (rootNode->children.empty()) {
// This can happen legitimately when:
// 1. No legal actions available (terminal state) - return default
// 2. Only one action and we early-exited without exploring - return index 0
// 3. Multiple actions but none expanded - this is a bug
if (rootNode->totalActions == 0) {
// Terminal state - no actions available, return default result
result.bestActionIndex = 0;
result.bestScore = 0.0;
result.nodesEvaluated = 0;
return result;
}
// Single action case - should have been expanded in BuildMCTSTree
if (rootNode->totalActions == 1) {
result.bestActionIndex = 0;
result.bestScore = 0.0;
return result;
}
// Multiple actions but no children expanded - this shouldn't happen
throw MCTSInternalError(
"MCTS search: Root has " + std::to_string(rootNode->totalActions) +
" actions but no children expanded - this indicates a bug in BuildMCTSTree");
}
// Find best child
if (const auto* bestChild = rootNode->GetBestFinalChild();
bestChild && bestChild->action && bestChild->actionIndex != SIZE_MAX) {
// Use actionIndex which is the index into the filtered actions from
// engine.getLegalActions()
result.bestActionIndex = bestChild->actionIndex;
result.bestScore = bestChild->lookaheadScore; // Use minimax value, not poisoned average
result.searchDepth = bestChild->depth;
result.nodesEvaluated = rootNode->visitCount;
// Check if we found a winning move
if (bestChild->gameState && bestChild->gameState->isTerminal() &&
bestChild->gameState->getWinner() == playerId_) {
result.foundWinningMove = true;
}
// Log results
LogSearchResults(rootNode.get(), bestChild, result);
}
return result;
}
auto AbstractMCTSAI::BuildMCTSTree(
const MCTSGameEngine& engine,
const MCTSGameState& initialState,
const std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode> {
// Create root node
// IMPORTANT: Use the initial state's current player, not playerId_
// node->playerId represents "whose turn it is", not "who we're searching for"
// This is critical for correct player flip tracking
auto root = std::make_unique<MCTSNode>(initialState.clone(), initialState.currentPlayerId(), 0);
// Set whether root is maximizing based on whether current player matches who we're searching
// for
root->isMaximizingPlayer = (initialState.currentPlayerId() == playerId_);
// Get legal actions from engine for the root state
// Root has 0 player flips
const auto rootActions =
engine.getLegalActions(initialState, playerId_, 0, config_.maxPlayerFlips);
// Early exit if only one action available - no need to search
if (rootActions.size() <= 1) {
// Expand the single action so Search() can return it
if (!rootActions.empty()) {
root->totalActions = 1;
[[maybe_unused]] auto* expanded = MCTSExpansion(root.get(), engine);
}
return root;
}
// Initialize action counter
root->totalActions = rootActions.size();
std::atomic<int> iterations{0};
if (config_.useMultithreading && config_.numThreads > 1) {
// Multithreaded MCTS
std::mutex treeMutex;
std::vector<std::future<void>> futures;
futures.reserve(config_.numThreads);
for (int threadId = 0; threadId < config_.numThreads; ++threadId) {
futures.push_back(std::async(std::launch::async, [&] {
while (std::chrono::steady_clock::now() < deadline) {
// Selection and Expansion (with lock - tree modification must be serialized)
MCTSNode* expanded;
{
std::lock_guard lock(treeMutex);
auto* selected = MCTSSelection(root.get());
if (!selected) break;
// Expansion modifies tree structure - must be inside lock
expanded = MCTSExpansion(selected, engine);
}
// Simulation can run in parallel (doesn't modify tree)
// Backpropagation (with lock - modifies node statistics)
{
const double reward = MCTSSimulation(
engine,
*expanded->gameState,
playerId_,
expanded->playerFlips);
std::lock_guard lock(treeMutex);
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
iterations.fetch_add(1);
}
}
}));
}
// Wait for all threads to complete
for (auto& future : futures) { future.wait(); }
} else {
// Single-threaded MCTS
while (std::chrono::steady_clock::now() < deadline) {
// Selection
auto* selected = MCTSSelection(root.get());
if (!selected) break;
// Expansion
auto* expanded = MCTSExpansion(selected, engine);
// Simulation
const double reward =
MCTSSimulation(engine, *expanded->gameState, playerId_, expanded->playerFlips);
// Backpropagation
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
++iterations;
// Early termination check
if (engine.shouldStopSearch(
*root->gameState,
iterations,
std::chrono::steady_clock::now())) {
break;
}
}
}
return root;
}
auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
MCTSNode* current = root;
while (!current->isTerminal && current->depth < config_.maxTreeDepth) {
if (current->CanExpand()) {
return current; // Node has untried actions
} else if (!current->children.empty()) {
current = current->GetBestChild(config_.explorationConstant);
if (!current) break;
} else {
break; // Leaf node
}
}
return current;
}
auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
-> MCTSNode* {
if (!node->CanExpand() || node->isTerminal) {
return node; // Nothing to expand
}
// Get next action to expand (sequential order)
const size_t actionIndex = node->nextUntriedActionIndex++;
// Get legal actions from engine (uses cached engine for performance)
// Use the parameterized version to respect player flips
const auto nodeActions = engine.getLegalActions(
*node->gameState,
playerId_,
node->playerFlips,
config_.maxPlayerFlips);
// Create new child node
if (actionIndex >= nodeActions.size()) {
throw MCTSInternalError(
"MCTS expansion: actionIndex (" + std::to_string(actionIndex) +
") >= nodeActions.size() (" + std::to_string(nodeActions.size()) +
") - this indicates a bug in action indexing");
}
// Get action weights from engine (for prior-weighted UCB)
const auto actionWeights = engine.getActionWeights(nodeActions, *node->gameState);
const auto& action = nodeActions[actionIndex];
const double actionWeight =
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
auto newState = engine.applyAction(*node->gameState, *action);
if (!newState) {
throw MCTSInternalError(
"MCTS expansion: engine.applyAction() returned nullptr for action " +
action->getDescription() + " - this indicates a game engine error");
}
// Determine if player changed
const MCTSPlayerId newPlayerId = newState->currentPlayerId();
const bool playerChanged = (newPlayerId != node->playerId);
// Calculate player flips and maximizing status
const int newPlayerFlips = node->playerFlips + (playerChanged ? 1 : 0);
// Node is maximizing if current player is the root player (playerId_)
const bool newIsMaximizing = (newPlayerId == playerId_);
auto child = std::make_unique<MCTSNode>(
action->clone(),
std::move(newState),
newPlayerId,
node->depth + 1,
actionIndex,
newPlayerFlips,
newIsMaximizing,
actionWeight); // Pass the action weight for prior-weighted UCB
// Set up child's untried actions if not terminal
if (!child->isTerminal) {
const auto childActions = engine.getLegalActions(
*child->gameState,
playerId_,
newPlayerFlips,
config_.maxPlayerFlips);
child->totalActions = childActions.size();
}
// Calculate immediate and lookahead scores from root player's perspective
child->immediateScore = engine.evaluateState(*child->gameState, playerId_);
child->lookaheadScore = child->immediateScore;
// Set parent and add to children
child->parent = node;
node->children.push_back(std::move(child));
return node->children.back().get();
}
auto AbstractMCTSAI::MCTSSimulation(
const MCTSGameEngine& engine,
const MCTSGameState& state,
const MCTSPlayerId startingPlayer,
const int startingPlayerFlips) const -> double {
if (state.isTerminal()) { return state.score(startingPlayer); }
// Create a mutable copy for simulation
auto currentState = state.clone();
int depth = 0;
int playerFlips = startingPlayerFlips; // Start from the expanded node's flip count
MCTSPlayerId previousPlayer = currentState->currentPlayerId();
// Simulate until terminal or max depth
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth) {
// Track player changes
const MCTSPlayerId currentPlayer = currentState->currentPlayerId();
if (currentPlayer != previousPlayer) {
playerFlips++;
previousPlayer = currentPlayer;
}
// Get legal actions with player flip tracking
const auto actions = engine.getLegalActions(
*currentState,
playerId_,
playerFlips,
config_.maxPlayerFlips);
if (actions.empty()) { break; }
// Determine if current player is maximizing or minimizing
// Maximizing: current player is root player (trying to maximize root player's score)
// Minimizing: current player is opponent (trying to minimize root player's score)
const bool isMaximizing = (currentPlayer == playerId_);
// Select action based on simulation policy
const size_t selectedIndex =
SelectSimulationAction(engine, *currentState, actions, isMaximizing);
if (selectedIndex >= actions.size()) { break; }
// Apply action
auto newState = engine.applyAction(*currentState, *actions[selectedIndex]);
if (!newState) { break; }
currentState = std::move(newState);
depth++;
}
return currentState->score(startingPlayer);
}
auto AbstractMCTSAI::MCTSBackpropagation(
MCTSNode* node,
const double reward,
const MCTSBackpropagationPolicy policy) const -> void {
// Backpropagation strategy is configured via MCTSConfig:
// - AVERAGING: Traditional MCTS averaging (for stochastic/single-player games)
// - MINIMAX: Minimax backup (for deterministic adversarial games)
const bool useMinimaxBackup = (policy == MCTSBackpropagationPolicy::MINIMAX);
while (node) {
node->visitCount++;
// Always track average for UCB
node->totalReward += reward;
node->averageReward = node->totalReward / node->visitCount;
// Update lookahead score based on strategy
if (useMinimaxBackup && !node->children.empty()) {
// Minimax backup: use best/worst child value for adversarial games
// This is correct when exploring opponent responses
double minmaxValue = node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
for (const auto& child : node->children) {
if (child->visitCount == 0) continue; // Unvisited children don't contribute
const double childValue = child->lookaheadScore;
if (node->isMaximizingPlayer) {
minmaxValue = std::max(minmaxValue, childValue);
} else {
minmaxValue = std::min(minmaxValue, childValue);
}
}
// Use minimax value if we found any visited children, else use average
if (minmaxValue != (node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max())) {
node->lookaheadScore = minmaxValue;
} else {
// No children visited yet, fall back to average
if (node->visitCount == 1) {
node->lookaheadScore = reward;
} else {
const double alpha = 1.0 / node->visitCount;
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
}
}
} else {
// Standard MCTS averaging (for maxPlayerFlips=0 or leaf nodes)
if (node->visitCount == 1) {
node->lookaheadScore = reward;
} else {
const double alpha = 1.0 / node->visitCount;
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
}
}
node = node->parent;
}
}
auto AbstractMCTSAI::SelectSimulationAction(
const MCTSGameEngine& engine,
const MCTSGameState& state,
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const bool isMaximizing) const -> size_t {
if (actions.empty()) {
throw MCTSInternalError(
"MCTSSimulation called with empty actions list - this indicates a bug in the "
"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);
}
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)];
}
// Fall back to random
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
return dis(gen);
}
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
// For adversarial search:
// - Maximizing nodes select action with HIGHEST score (best for root player)
// - Minimizing nodes select action with LOWEST score (worst for root player)
// First, filter out obviously bad moves (e.g., BECOME_OUTLAW)
const auto filteredIndices = engine.filterActions(actions, state);
if (filteredIndices.empty()) {
// If all actions filtered out, fall back to first action
return 0;
}
double bestScore = isMaximizing ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
size_t bestIndex = filteredIndices[0];
for (const size_t i : filteredIndices) {
// CRITICAL: Always get score from ROOT player's perspective for adversarial search
// If we use currentPlayerId, opponent actions would be scored from their
// perspective, causing them to select moves that help themselves instead of hurt
// us!
const double score = engine.getActionScore(state, *actions[i], playerId_);
const bool shouldSelect = isMaximizing ? (score > bestScore) : (score < bestScore);
if (shouldSelect) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
case MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE: {
// Score all actions and weight by ranking
std::vector<std::pair<size_t, double>> scores;
scores.reserve(actions.size());
for (size_t i = 0; i < actions.size(); ++i) {
// CRITICAL: Always get score from ROOT player's perspective for adversarial search
const double score = engine.getActionScore(state, *actions[i], playerId_);
scores.emplace_back(i, score);
}
// Sort by score
// - Maximizing: highest scores first (prefer actions that maximize root player's score)
// - Minimizing: lowest scores first (prefer actions that minimize root player's score)
if (isMaximizing) {
std::ranges::sort(scores, [](const auto& a, const auto& b) {
return a.second > b.second; // Descending
});
} else {
std::ranges::sort(scores, [](const auto& a, const auto& b) {
return a.second < b.second; // Ascending
});
}
// Create weights based on ranking
std::vector<double> weights;
weights.reserve(scores.size());
for (size_t i = 0; i < scores.size(); ++i) {
weights.push_back(1.0 / (static_cast<double>(i) + 1.0));
}
// Select based on weights
std::discrete_distribution<> dis(weights.begin(), weights.end());
return scores[dis(gen)].first;
}
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
// Get heuristic weights from engine (fast O(1) per action)
const auto weights = engine.getActionWeights(actions, state);
// Filter out zero-weight actions
std::vector<size_t> validIndices;
std::vector<double> validWeights;
validIndices.reserve(actions.size());
validWeights.reserve(actions.size());
for (size_t i = 0; i < weights.size() && i < actions.size(); ++i) {
if (weights[i] > 0.0) {
validIndices.push_back(i);
validWeights.push_back(weights[i]);
}
}
// If all actions filtered out, this is a bug in the weighting logic
if (validWeights.empty()) {
throw MCTSInternalError(
"MCTS simulation: All actions have zero weight in WEIGHTED_HEURISTIC "
"policy (action count: " +
std::to_string(actions.size()) + ") - this indicates incorrect weighting");
}
// Select based on heuristic weights using discrete_distribution
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
return validIndices[dis(gen)];
}
}
// Default to random
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
return dis(gen);
}
auto AbstractMCTSAI::FindNodeAtDepthWithHash(
const MCTSNode* root,
const int maxDepth,
const uint64_t targetHash) -> const MCTSNode* {
if (!root || root->depth >= maxDepth || root->stateHash == targetHash) { return root; }
// Breadth-first search for matching hash at specific depth
std::vector<const MCTSNode*> currentLevel = {root};
for (int d = 0; d < maxDepth && !currentLevel.empty(); ++d) {
std::vector<const MCTSNode*> nextLevel;
for (const auto* node : currentLevel) {
for (const auto& child : node->children) {
if (child->stateHash == targetHash && child->depth <= maxDepth) {
return child.get();
}
if (child->depth < maxDepth) { nextLevel.push_back(child.get()); }
}
}
currentLevel = std::move(nextLevel);
}
return nullptr;
}
auto AbstractMCTSAI::LogSearchResults(
const MCTSNode* rootNode,
const MCTSNode* bestChild,
const SearchResult& result) -> void {
// Log selected command
const std::string selectedDesc =
bestChild->action ? bestChild->action->getDescription() : "Unknown";
printf("MCTS: Selected %s (visit:%d, reward:%.2f, lookahead:%.2f) from %zu options\n",
selectedDesc.c_str(),
bestChild->visitCount,
bestChild->averageReward,
bestChild->lookaheadScore,
rootNode->children.size());
// Log top 3 actions by visits
std::vector<const MCTSNode*> sortedChildren;
sortedChildren.reserve(rootNode->children.size());
for (const auto& child : rootNode->children) { sortedChildren.push_back(child.get()); }
std::ranges::sort(sortedChildren, [](const auto* a, const auto* b) {
return a->visitCount > b->visitCount;
});
printf("MCTS: Top actions by visits:\n");
for (size_t i = 0; i < std::min(static_cast<size_t>(3), sortedChildren.size()); ++i) {
const auto* child = sortedChildren[i];
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
i,
child->visitCount,
child->immediateScore,
child->lookaheadScore);
// Show the action's own description
if (child->action) { printf(" %s", child->action->getDescription().c_str()); }
// Show sequence preview
if (!child->children.empty()) {
const MCTSNode* bestNext = nullptr;
int maxVisits = 0;
for (const auto& grandchild : child->children) {
if (grandchild->visitCount > maxVisits) {
maxVisits = grandchild->visitCount;
bestNext = grandchild.get();
}
}
if (bestNext && bestNext->action) {
printf(" -> %s", bestNext->action->getDescription().c_str());
}
}
printf("\n");
}
printf("MCTS: Tree stats - max depth:%d, total nodes:%d, root visits:%d\n",
result.searchDepth,
result.nodesEvaluated,
rootNode->visitCount);
// Log best sequence from chosen action (following most-visited path)
std::vector<const MCTSNode*> bestSequence;
bestSequence.reserve(10);
const MCTSNode* current = bestChild;
double sequenceScore = bestChild->averageReward;
while (current && bestSequence.size() < 10) {
bestSequence.push_back(current);
if (current->children.empty()) break;
// Find most visited child (standard MCTS principal variation)
const MCTSNode* bestChildNode = nullptr;
int maxVisits = 0;
for (const auto& child : current->children) {
if (child->visitCount > maxVisits) {
maxVisits = child->visitCount;
bestChildNode = child.get();
}
}
current = bestChildNode;
if (current) { sequenceScore = current->averageReward; }
}
if (!bestSequence.empty()) {
printf("MCTS: Best sequence from chosen action (final: %.2f):\n", sequenceScore);
for (size_t i = 0; i < bestSequence.size(); ++i) {
const auto* node = bestSequence[i];
printf(" %zu.", i + 1);
if (node->action) { printf(" %s", node->action->getDescription().c_str()); }
printf(" (visits:%d, immediate:%.2f, lookahead:%.2f)\n",
node->visitCount,
node->immediateScore,
node->lookaheadScore);
// For non-root nodes in the sequence, show what the top alternatives were
// This helps diagnose if opponent moves are being properly explored
if (i > 0 && node->parent && !node->parent->children.empty()) {
// Collect all siblings (including this node) and sort by visit count
std::vector<const MCTSNode*> siblings;
siblings.reserve(node->parent->children.size());
for (const auto& child : node->parent->children) {
if (!child->isRedundant) { siblings.push_back(child.get()); }
}
// Sort by visit count (descending)
std::ranges::sort(siblings, [](const MCTSNode* a, const MCTSNode* b) {
return a->visitCount > b->visitCount;
});
// Show top 3 alternatives at this decision point
printf(" Alternatives at this node (%zu total):\n", siblings.size());
const size_t topN = std::min(siblings.size(), size_t(3));
for (size_t j = 0; j < topN; ++j) {
const auto* alt = siblings[j];
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
j,
alt->visitCount,
alt->immediateScore,
alt->lookaheadScore);
if (alt->action) { printf(" %s", alt->action->getDescription().c_str()); }
printf("\n");
}
}
}
}
}
} // namespace shardok::mcts
@@ -0,0 +1,92 @@
//
// Abstract MCTS AI implementation - game agnostic
//
#ifndef EAGLE0_ABSTRACT_MCTSAI_HPP
#define EAGLE0_ABSTRACT_MCTSAI_HPP
#include <chrono>
#include <memory>
#include <vector>
#include "MCTSAction.hpp"
#include "MCTSGameEngine.hpp"
#include "MCTSGameState.hpp"
#include "MCTSNode.hpp"
#include "MCTSTypes.hpp"
namespace shardok {
namespace mcts {
class AbstractMCTSAI {
public:
// Search result structure
struct SearchResult {
size_t bestActionIndex = 0;
double bestScore = 0.0;
int searchDepth = 0;
int nodesEvaluated = 0;
std::chrono::milliseconds searchTime{0};
bool foundWinningMove = false;
};
explicit AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config = MCTSConfig{});
// Main search interface
[[nodiscard]] auto Search(
const MCTSGameEngine& engine,
const MCTSGameState& initialState,
std::chrono::milliseconds timeLimit) const -> SearchResult;
// Configuration
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config_; }
void SetConfig(const MCTSConfig& newConfig) { config_ = newConfig; }
[[nodiscard]] auto FindNodeAtDepthWithHash(
const MCTSNode* root,
int maxDepth,
uint64_t targetHash) -> const MCTSNode*;
private:
MCTSPlayerId playerId_;
MCTSConfig config_;
// Core MCTS algorithm
[[nodiscard]] auto BuildMCTSTree(
const MCTSGameEngine& engine,
const MCTSGameState& initialState,
std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode>;
// MCTS phases
[[nodiscard]] auto MCTSSelection(MCTSNode* root) const -> MCTSNode*;
[[nodiscard]] auto MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
-> MCTSNode*;
[[nodiscard]] auto MCTSSimulation(
const MCTSGameEngine& engine,
const MCTSGameState& state,
MCTSPlayerId startingPlayer,
int startingPlayerFlips = 0) const -> double;
auto MCTSBackpropagation(MCTSNode* node, double reward, MCTSBackpropagationPolicy policy) const
-> void;
// Helper functions
[[nodiscard]] auto SelectSimulationAction(
const MCTSGameEngine& engine,
const MCTSGameState& state,
const std::vector<std::unique_ptr<MCTSAction>>& actions,
bool isMaximizing) const -> size_t;
// Logging
static auto LogSearchResults(
const MCTSNode* rootNode,
const MCTSNode* bestChild,
const SearchResult& result) -> void;
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
@@ -0,0 +1,93 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "mcts_types",
hdrs = ["MCTSTypes.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
)
cc_library(
name = "mcts_action",
hdrs = ["MCTSAction.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
)
cc_library(
name = "mcts_game_state",
hdrs = ["MCTSGameState.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
deps = [
":mcts_types",
],
)
cc_library(
name = "mcts_game_engine",
srcs = ["MCTSGameEngine.cpp"],
hdrs = ["MCTSGameEngine.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
deps = [
":mcts_action",
":mcts_game_state",
":mcts_types",
],
)
cc_library(
name = "mcts_node",
hdrs = ["MCTSNode.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
deps = [
":mcts_action",
":mcts_game_state",
":mcts_types",
],
)
cc_library(
name = "abstract_mcts_ai",
srcs = ["AbstractMCTSAI.cpp"],
hdrs = ["AbstractMCTSAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
deps = [
":mcts_action",
":mcts_game_engine",
":mcts_game_state",
":mcts_node",
":mcts_types",
],
)
# Individual targets are exposed above - no need for a catch-all target
# Each component should be imported explicitly by its consumers
@@ -0,0 +1,35 @@
//
// Abstract action interface for MCTS
//
#ifndef EAGLE0_MCTS_ACTION_HPP
#define EAGLE0_MCTS_ACTION_HPP
#include <memory>
#include <string>
namespace shardok {
namespace mcts {
// Abstract interface for game actions
class MCTSAction {
public:
virtual ~MCTSAction() = default;
// Get a unique index for this action (used for command indexing)
[[nodiscard]] virtual size_t getIndex() const = 0;
// Get a human-readable description for debugging/logging
[[nodiscard]] virtual std::string getDescription() const = 0;
// Create a deep copy of this action
[[nodiscard]] virtual std::unique_ptr<MCTSAction> clone() const = 0;
// Check if two actions are equivalent
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_MCTS_ACTION_HPP
@@ -0,0 +1,148 @@
//
// Default implementations for MCTSGameEngine
//
#include "MCTSGameEngine.hpp"
#include <algorithm>
#include <limits>
#include <random>
#include <vector>
#include "MCTSTypes.hpp" // For MCTSInternalError
namespace shardok {
namespace mcts {
double MCTSGameEngine::simulateRandomPlayout(
const MCTSGameState& state,
MCTSPlayerId playerId,
int maxDepth,
MCTSSimulationPolicy policy) const {
// Clone state once and mutate it throughout simulation for efficiency
auto currentState = state.clone();
int depth = 0;
// Use thread-local random generator for thread safety
static thread_local std::mt19937 gen(std::random_device{}());
// Simulate until terminal or max depth
while (!currentState->isTerminal() && depth < maxDepth) {
auto actions = getLegalActions(*currentState, playerId, 0, 0);
if (actions.empty()) { break; }
size_t selectedIndex = 0;
// Select action based on policy
switch (policy) {
case MCTSSimulationPolicy::RANDOM: {
std::uniform_int_distribution<> dis(0, actions.size() - 1);
selectedIndex = dis(gen);
break;
}
case MCTSSimulationPolicy::FILTERED_RANDOM: {
auto filteredIndices = filterActions(actions, *currentState);
if (!filteredIndices.empty()) {
std::uniform_int_distribution<> dis(0, filteredIndices.size() - 1);
selectedIndex = filteredIndices[dis(gen)];
} else {
// Fall back to random if no actions pass filter
std::uniform_int_distribution<> dis(0, actions.size() - 1);
selectedIndex = dis(gen);
}
break;
}
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
double bestScore = -std::numeric_limits<double>::infinity();
for (size_t i = 0; i < actions.size(); ++i) {
double score = getActionScore(
*currentState,
*actions[i],
currentState->currentPlayerId());
if (score > bestScore) {
bestScore = score;
selectedIndex = i;
}
}
break;
}
case MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE: {
// Score all actions and weight by ranking
std::vector<std::pair<size_t, double>> scores;
scores.reserve(actions.size());
for (size_t i = 0; i < actions.size(); ++i) {
double score = getActionScore(
*currentState,
*actions[i],
currentState->currentPlayerId());
scores.emplace_back(i, score);
}
// Sort by score (descending)
std::sort(scores.begin(), scores.end(), [](const auto& a, const auto& b) {
return a.second > b.second;
});
// Create weights based on ranking (1/rank)
std::vector<double> weights;
weights.reserve(scores.size());
for (size_t i = 0; i < scores.size(); ++i) { weights.push_back(1.0 / (i + 1.0)); }
// Select based on weights
std::discrete_distribution<> dis(weights.begin(), weights.end());
selectedIndex = scores[dis(gen)].first;
break;
}
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
// Get heuristic weights (fast O(1) per action)
const auto weights = getActionWeights(actions, *currentState);
// Filter out zero-weight actions
std::vector<size_t> validIndices;
std::vector<double> validWeights;
validIndices.reserve(actions.size());
validWeights.reserve(actions.size());
for (size_t i = 0; i < weights.size() && i < actions.size(); ++i) {
if (weights[i] > 0.0) {
validIndices.push_back(i);
validWeights.push_back(weights[i]);
}
}
// If all actions filtered out, this is a bug in the weighting logic
if (validWeights.empty()) {
throw MCTSInternalError(
"MCTS simulation (playout): All actions have zero weight in "
"WEIGHTED_HEURISTIC policy (action count: " +
std::to_string(actions.size()) +
") - this indicates incorrect weighting");
}
// Select based on heuristic weights
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
selectedIndex = validIndices[dis(gen)];
break;
}
}
// Apply selected action using mutable version for efficiency
applyActionMutable(currentState, *actions[selectedIndex]);
if (!currentState) {
break; // Failed to apply action
}
depth++;
}
// Return evaluation from original player's perspective
return evaluateState(*currentState, playerId);
}
} // namespace mcts
} // namespace shardok
@@ -0,0 +1,119 @@
//
// Abstract game engine interface for MCTS
//
#ifndef EAGLE0_MCTS_GAME_ENGINE_HPP
#define EAGLE0_MCTS_GAME_ENGINE_HPP
#include <chrono>
#include <memory>
#include <vector>
#include "MCTSAction.hpp"
#include "MCTSGameState.hpp"
#include "MCTSTypes.hpp"
namespace shardok {
namespace mcts {
// Abstract interface for game engines
class MCTSGameEngine {
public:
virtual ~MCTSGameEngine() = default;
// Apply an action to a state and return the resulting state
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action) const = 0;
// Apply an action to a mutable state in-place (for efficient simulation)
// Default: clone, apply, and move the result back
// Override this for better performance
virtual void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
const {
state = applyAction(*state, action);
}
// Get all legal actions for the current state with player flip tracking
// Default implementation ignores flip tracking and calls base version
[[nodiscard]] virtual std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
const MCTSGameState& state,
MCTSPlayerId /*rootPlayerId*/,
int /*currentPlayerFlips*/,
int /*maxPlayerFlips*/) const = 0;
// Check if a state is terminal
[[nodiscard]] virtual bool isTerminal(const MCTSGameState& state) const = 0;
// Evaluate a state from the perspective of a player
[[nodiscard]] virtual double evaluateState(const MCTSGameState& state, MCTSPlayerId playerId)
const = 0;
// Filter actions based on game-specific heuristics
// Returns indices of actions to keep
// Default: no filtering (return all indices)
[[nodiscard]] virtual std::vector<size_t> filterActions(
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const MCTSGameState& /*state*/) const {
std::vector<size_t> indices;
indices.reserve(actions.size());
for (size_t i = 0; i < actions.size(); ++i) { indices.push_back(i); }
return indices;
}
// Get heuristic weights for actions (used by WEIGHTED_HEURISTIC simulation policy)
// Returns weights corresponding to each action (same size as actions vector)
// Weight of 0.0 = never select, higher = more likely to select
// Default: uniform weights (all actions equally likely)
[[nodiscard]] virtual std::vector<double> getActionWeights(
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const MCTSGameState& /*state*/) const {
// Default: uniform weights
return std::vector<double>(actions.size(), 1.0);
}
// Simulate a random playout from the given state
// Default implementation uses policy to select actions
[[nodiscard]] virtual double simulateRandomPlayout(
const MCTSGameState& state,
MCTSPlayerId playerId,
int maxDepth,
MCTSSimulationPolicy policy) const;
// Get the immediate score of applying an action
// Default: apply the action and evaluate the resulting state
[[nodiscard]] virtual double getActionScore(
const MCTSGameState& state,
const MCTSAction& action,
MCTSPlayerId playerId) const {
auto newState = applyAction(state, action);
if (!newState) { return 0.0; }
return evaluateState(*newState, playerId);
}
// Check if we should stop searching (e.g., time limit, found winning move)
[[nodiscard]] virtual bool shouldStopSearch(
const MCTSGameState& /*state*/,
int /*iterations*/,
std::chrono::steady_clock::time_point /*startTime*/) const {
// Default: no early stopping
return false;
}
// Map a filtered action index back to the original unfiltered index
// This is needed when getLegalActions() applies filtering - the returned actions
// may be a subset of all available actions, and this maps back to the original index.
// Default implementation: no filtering, so filtered index = original index
[[nodiscard]] virtual size_t mapFilteredIndexToOriginal(
size_t filteredIndex,
const MCTSGameState& state) const {
// Default: no filtering, index stays the same
(void)state; // Suppress unused parameter warning
return filteredIndex;
}
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_MCTS_GAME_ENGINE_HPP
@@ -0,0 +1,50 @@
//
// Abstract game state interface for MCTS
//
#ifndef EAGLE0_MCTS_GAME_STATE_HPP
#define EAGLE0_MCTS_GAME_STATE_HPP
#include <cstdint>
#include <memory>
#include <string>
#include "MCTSTypes.hpp"
namespace shardok {
namespace mcts {
// Abstract interface for game states
class MCTSGameState {
public:
virtual ~MCTSGameState() = default;
// Compute hash for transposition table
[[nodiscard]] virtual uint64_t hash() const = 0;
// Evaluate the state from the perspective of the given player
[[nodiscard]] virtual double score(MCTSPlayerId playerId) const = 0;
// Get the player whose turn it is
[[nodiscard]] virtual MCTSPlayerId currentPlayerId() const = 0;
// Check if the game has ended
[[nodiscard]] virtual bool isTerminal() const = 0;
// Create a deep copy of the state
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> clone() const = 0;
// Check if two states are equivalent
[[nodiscard]] virtual bool equals(const MCTSGameState& other) const = 0;
// Get winner if terminal, or -1 if not terminal or draw
[[nodiscard]] virtual MCTSPlayerId getWinner() const = 0;
// Optional: Get a string representation for debugging
[[nodiscard]] virtual std::string toString() const { return "MCTSGameState"; }
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_MCTS_GAME_STATE_HPP
@@ -0,0 +1,231 @@
//
// Abstract MCTS Node structure for game-agnostic implementation
//
#ifndef EAGLE0_ABSTRACT_MCTSNODE_HPP
#define EAGLE0_ABSTRACT_MCTSNODE_HPP
#include <cmath>
#include <limits>
#include <memory>
#include <vector>
#include "MCTSAction.hpp"
#include "MCTSGameState.hpp"
#include "MCTSTypes.hpp"
namespace shardok {
namespace mcts {
// Abstract MCTS Node structure
struct MCTSNode {
// Action information
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
// Score information
double immediateScore = 0.0;
double lookaheadScore = 0.0;
// Game state after this action
std::unique_ptr<MCTSGameState> gameState;
// MCTS statistics
int visitCount = 0;
double totalReward = 0.0;
double averageReward = 0.0;
mutable double ucb1Value = 0.0;
double actionWeight = 1.0; // Prior probability/weight for this action (from heuristics)
// Tree structure
std::vector<std::unique_ptr<MCTSNode>> children;
size_t nextUntriedActionIndex = 0; // Next action to expand
size_t totalActions = 0; // Total number of available actions
MCTSNode* parent = nullptr;
// Game context
MCTSPlayerId playerId;
int depth = 0;
bool isTerminal = false;
int playerFlips = 0; // Number of times the active player has changed from root player
bool isMaximizingPlayer = true; // True if this node is maximizing for root player
// Transposition detection
uint64_t stateHash = 0;
bool isRedundant = false; // True if this node represents a duplicate state
// Constructor for root node
MCTSNode(std::unique_ptr<MCTSGameState> state, MCTSPlayerId pid, int d)
: gameState(std::move(state)),
playerId(pid),
depth(d),
playerFlips(0),
isMaximizingPlayer(true) {
if (gameState) {
stateHash = gameState->hash();
isTerminal = gameState->isTerminal();
}
}
// Constructor for child node
MCTSNode(
std::unique_ptr<MCTSAction> act,
std::unique_ptr<MCTSGameState> state,
MCTSPlayerId pid,
int d,
size_t actIdx = SIZE_MAX,
int flips = 0,
bool isMaximizing = true,
double weight = 1.0)
: action(std::move(act)),
actionIndex(actIdx),
gameState(std::move(state)),
actionWeight(weight),
playerId(pid),
depth(d),
playerFlips(flips),
isMaximizingPlayer(isMaximizing) {
if (gameState) {
stateHash = gameState->hash();
isTerminal = gameState->isTerminal();
}
}
// Iterative destructor to avoid stack overflow with deep trees
~MCTSNode() {
std::vector<std::unique_ptr<MCTSNode>> nodesToDestroy;
nodesToDestroy.swap(children);
while (!nodesToDestroy.empty()) {
std::vector<std::unique_ptr<MCTSNode>> currentBatch;
currentBatch.swap(nodesToDestroy);
for (const auto& node : currentBatch) {
if (node && !node->children.empty()) {
for (auto& child : node->children) {
nodesToDestroy.push_back(std::move(child));
}
node->children.clear();
}
}
}
}
// Calculate UCB1 value for this node from parent's perspective
// Uses prior-weighted formula similar to AlphaGo:
// UCB = Q + c * P * sqrt(N_parent) / (1 + N_child)
// Where P is the action weight (prior probability from heuristics)
[[nodiscard]] double CalculateUCB1(
const double explorationConstant,
const int parentVisitCount,
const bool parentIsMaximizing) const {
// Exploitation: use lookahead score (minimax value)
// For minimizing nodes, negate the score to prefer low child values
const double exploitationValue = parentIsMaximizing ? lookaheadScore : -lookaheadScore;
// Exploration: prior-weighted formula (AlphaGo-style)
// Actions with weight 0.0 (like FLEE_COMMAND) get no exploration bonus
// Unvisited nodes get: c * weight * sqrt(N_parent)
// This prevents bad actions from dominating exploration due to infinite UCB
const double explorationValue = explorationConstant * actionWeight *
std::sqrt(parentVisitCount) / (1.0 + visitCount);
return exploitationValue + explorationValue;
}
// Check if this node can be expanded
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
// Get best child based on UCB1
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
if (children.empty()) return nullptr;
MCTSNode* bestChild = nullptr;
double bestValue = -std::numeric_limits<double>::max();
for (auto& child : children) {
// Skip redundant nodes
if (child->isRedundant) continue;
// Calculate UCB1 value using the helper function
const double value =
child->CalculateUCB1(explorationConstant, visitCount, isMaximizingPlayer);
// Debug logging for UCB selection
static bool enableUCBDebug = false;
if (enableUCBDebug && child->visitCount > 0) {
const double exploitationValue =
isMaximizingPlayer ? child->lookaheadScore : -child->lookaheadScore;
const double explorationValue =
explorationConstant * std::sqrt(std::log(visitCount) / child->visitCount);
printf(" UCB: %s lookahead=%.2f expl=%.2f (+%.2f) = %.2f [%s]\n",
isMaximizingPlayer ? "MAX" : "MIN",
child->lookaheadScore,
exploitationValue,
explorationValue,
value,
child->action ? child->action->getDescription().c_str() : "root");
}
if (value > bestValue) {
bestValue = value;
bestChild = child.get();
}
}
return bestChild;
}
// Get best child based on visit count (for final selection)
[[nodiscard]] MCTSNode* GetBestFinalChild() const {
if (children.empty()) return nullptr;
MCTSNode* bestChild = nullptr;
int bestVisits = 0;
double bestScore = isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
for (const auto& child : children) {
// Skip redundant nodes
if (child->isRedundant) continue;
// Prefer most-visited node (robust child selection)
if (child->visitCount > bestVisits) {
bestVisits = child->visitCount;
bestScore = child->lookaheadScore;
bestChild = child.get();
} else if (child->visitCount == bestVisits) {
// Tie-break on lookahead score (minimax value, not poisoned average)
// Maximizing: prefer higher score (better for root player)
// Minimizing: prefer lower score (worse for root player)
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
: (child->lookaheadScore < bestScore);
if (shouldReplace) {
bestScore = child->lookaheadScore;
bestChild = child.get();
}
}
}
// If no child was visited, fall back to lookahead score
if (!bestChild && !children.empty()) {
for (const auto& child : children) {
if (child->isRedundant) continue;
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
: (child->lookaheadScore < bestScore);
if (shouldReplace) {
bestScore = child->lookaheadScore;
bestChild = child.get();
}
}
}
return bestChild;
}
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
@@ -0,0 +1,54 @@
//
// Core types for abstract MCTS implementation
//
#ifndef EAGLE0_MCTS_TYPES_HPP
#define EAGLE0_MCTS_TYPES_HPP
#include <stdexcept>
#include <string>
namespace shardok {
namespace mcts {
// Exception thrown when MCTS encounters an internal error that indicates a bug
class MCTSInternalError : public std::logic_error {
public:
explicit MCTSInternalError(const std::string& message) : std::logic_error(message) {}
};
// Abstract player identifier type
using MCTSPlayerId = int;
// Simulation policy for MCTS rollouts
enum class MCTSSimulationPolicy {
RANDOM, // Pure random selection
FILTERED_RANDOM, // Random from filtered actions
BEST_IMMEDIATE, // Choose best immediate score
WEIGHTED_BEST_IMMEDIATE, // Random weighted by score ranking
WEIGHTED_HEURISTIC // Random weighted by fast heuristics (no score evaluation)
};
// Backpropagation policy for MCTS tree updates
enum class MCTSBackpropagationPolicy {
AVERAGING, // Traditional MCTS averaging (for stochastic/single-player games)
MINIMAX // Minimax backup (for deterministic adversarial games)
};
// Configuration for MCTS algorithm
struct MCTSConfig {
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
int maxSimulationDepth = 1000; // Maximum depth for rollout
int maxTreeDepth = 2000; // Maximum tree depth to prevent stack overflow
bool useMultithreading = true; // Enable parallel MCTS
int numThreads = 16; // Number of threads for parallel MCTS
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
int maxPlayerFlips = 0; // Maximum number of player changes to explore (0 = stop at first
// flip, 1 = explore through opponent's response, etc.)
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_MCTS_TYPES_HPP
@@ -9,7 +9,6 @@
#include <ranges>
#include <unordered_map>
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
@@ -76,30 +75,28 @@ auto MinDistanceIncludingBraving(
auto EffectiveDistance(
const Unit* unit,
const HexMap* map,
const MapId& mapId,
const APDCache& apdCache,
const AttackLocations& attackLocations,
const SettingsGetter& settings,
const int braveWaterCost) -> DIST_T {
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> DIST_T {
return EffectiveDistance(
unit,
map,
mapId,
apdCache,
attackLocations.LocationsWithEnemyInRange(unit),
settings,
apdCache,
battalionTypeGetter,
braveWaterCost);
}
auto EffectiveDistance(
const Unit* unit,
const HexMap* map,
const MapId& mapId,
const APDCache& apdCache,
const CoordsSet& locations,
const SettingsGetter& settings,
const int braveWaterCost) -> DIST_T {
const auto& battType = settings.GetBattalionType(unit->battalion().type());
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> DIST_T {
const auto mapId = ActionPointDistancesCache::GetMapId(map);
const auto& battType = battalionTypeGetter(unit->battalion().type());
const auto* notBravingApd = apdCache->GetRaw(map, mapId, battType, false);
const ActionPointDistances* bravingApd = nullptr;
if (battType->allowsBraveWater) {
@@ -132,12 +129,12 @@ auto GenerateTargetPriorities(
const vector<const Unit*>& remainingUnits,
const APDCache& apdCache,
const ALCache& alCache,
const MapId& mapId,
const SettingsGetter& settings,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const bool isLateGame) -> vector<TargetPriorityList> {
auto cc = map->column_count();
const auto braveWaterCost = settings.Backing().brave_water_action_point_cost();
const auto mapId = ActionPointDistancesCache::GetMapId(map);
vector<TargetPriorityList> allTargetsUnitsAndDistances{};
allTargetsUnitsAndDistances.reserve(remainingUnits.size());
@@ -161,7 +158,7 @@ auto GenerateTargetPriorities(
vector<TargetAndDistance> targetsWithDistance;
// Get APDs directly from cache (now with built-in thread-local optimization)
const auto& battType = settings.GetBattalionType(unit->battalion().type());
const auto& battType = battalionTypeGetter(unit->battalion().type());
const auto* notBravingApd = apdCache->GetRaw(map, mapId, battType, false);
const ActionPointDistances* bravingApd = nullptr;
if (battType->allowsBraveWater) {
@@ -5,12 +5,12 @@
#ifndef EAGLE0_AIATTACKGROUPS_HPP
#define EAGLE0_AIATTACKGROUPS_HPP
#include <functional>
#include <vector>
#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/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/player_info.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
@@ -22,6 +22,8 @@ using Unit = net::eagle0::shardok::storage::fb::Unit;
using net::eagle0::shardok::storage::fb::PlayerInfo;
using std::vector;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
struct TargetAndAttackLocations {
Coords target;
CoordsSet attackLocations;
@@ -41,20 +43,18 @@ struct TargetPriorityList {
auto EffectiveDistance(
const Unit* unit,
const HexMap* map,
const MapId& mapId,
const APDCache& apdCache,
const AttackLocations& attackLocations,
const SettingsGetter& settings,
int braveWaterCost) -> DIST_T;
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> DIST_T;
auto EffectiveDistance(
const Unit* unit,
const HexMap* map,
const MapId& mapId,
const APDCache& apdCache,
const CoordsSet& locations,
const SettingsGetter& settings,
int braveWaterCost) -> DIST_T;
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> DIST_T;
auto EffectiveDistance(
const Unit* unit,
@@ -71,8 +71,8 @@ auto GenerateTargetPriorities(
const vector<const Unit*>& remainingUnits,
const APDCache& apdCache,
const ALCache& alCache,
const MapId& mapId,
const SettingsGetter& settings,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
bool isLateGame = false) -> vector<TargetPriorityList>;
} // namespace shardok
@@ -4,6 +4,7 @@
#include "AIAttackerStrategySelector.hpp"
#include "AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIFleeDecisionCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -20,9 +21,11 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
const PlayerId attackerPid,
const GameStateW& gameState,
const CoordsSet& criticalTileCoords,
int maxRounds,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
uint32_t attackerUnitCount = 0;
@@ -63,12 +66,14 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
if (canFlee && AIFleeDecisionCalculator::ShouldConsiderFleeing(
attackerPid,
gameState,
settings,
maxRounds,
FLEE_CONSIDERATION_THRESHOLD)) {
chosenStrategy = FleeStrategy;
} else if (const CoordsSet startCrossingLocations =
waterCrossingCommandChooser
.StartCrossingFrom(settings, gameState, criticalTileCoords);
waterCrossingCommandChooser.StartCrossingFrom(
battalionTypeGetter,
gameState,
criticalTileCoords);
!startCrossingLocations.empty()) {
chosenStrategy = CrossRiversStrategy(startCrossingLocations);
} else if (attackerUnitCount < criticalTileCoords.size()) {
@@ -83,8 +88,8 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
attackerUnits,
apdCache,
alCache,
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
settings));
battalionTypeGetter,
braveWaterCost));
}
// If any critical tile is occupied by the defender, attack the castles.
// Otherwise, try to hold the castles.
@@ -100,8 +105,8 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
attackerUnits,
apdCache,
alCache,
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
settings));
battalionTypeGetter,
braveWaterCost));
} else {
chosenStrategy = HoldCastlesStrategy;
}
@@ -6,9 +6,11 @@
#define EAGLE0_AIATTACKERSTRATEGYSELECTOR_HPP
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
namespace shardok {
@@ -19,9 +21,11 @@ public:
PlayerId attackerPid,
const GameStateW& gameState,
const CoordsSet& criticalTileCoords,
int maxRounds,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
const vector<CommandProto>& availableCommands) -> AIStrategy;
};
@@ -0,0 +1,560 @@
//
// Command evaluator for AI lookahead search.
// Extracted from AIScoreCalculator to separate concerns.
//
#include "AICommandEvaluator.hpp"
#include <chrono>
#include <cmath>
#include <future>
#include <limits>
#include "AICommandFilter.hpp"
#include "TranspositionTable.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.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/util/HexCubeUtils.hpp"
namespace shardok {
// No need to forward declare internal functions - use the public interface instead
// Helper constants and static variables
static const std::vector<double> _averageSequence = {0.5};
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
#define MULTITHREAD true
#define LOGGING_ 0
// Helper function to determine if a command type is deterministic
static auto IsDeterministic(const CommandType type) -> bool {
switch (type) {
case net::eagle0::shardok::common::MOVE_COMMAND:
case net::eagle0::shardok::common::CONTROL_COMMAND:
case net::eagle0::shardok::common::METEOR_START_COMMAND:
case net::eagle0::shardok::common::METEOR_TARGET_COMMAND:
case net::eagle0::shardok::common::METEOR_CANCEL_COMMAND:
case net::eagle0::shardok::common::END_TURN_COMMAND:
case net::eagle0::shardok::common::PLACE_UNIT_COMMAND:
case net::eagle0::shardok::common::PLACE_HIDDEN_UNIT_COMMAND:
case net::eagle0::shardok::common::UNIT_STOP_COMMAND:
case net::eagle0::shardok::common::UNIT_REST_COMMAND:
case net::eagle0::shardok::common::FLEE_COMMAND:
case net::eagle0::shardok::common::REINFORCE_COMMAND:
case net::eagle0::shardok::common::RETREAT_COMMAND:
case net::eagle0::shardok::common::END_PLAYER_SETUP_COMMAND:
case net::eagle0::shardok::common::HIDE_COMMAND:
case net::eagle0::shardok::common::FORTIFY_COMMAND:
case net::eagle0::shardok::common::BECOME_OUTLAW_COMMAND:
case net::eagle0::shardok::common::HOLY_WAVE_COMMAND:
case net::eagle0::shardok::common::REPAIR_COMMAND: return true;
default: return false;
}
}
// Helper function to sort commands by score
static auto CommandSorter(
const AICommandEvaluator::IndexAndScore& l,
const AICommandEvaluator::IndexAndScore& r) -> bool {
if (l.lookaheadScore < r.lookaheadScore) return true;
if (l.lookaheadScore > r.lookaheadScore) return false;
// At this point the scores are tied
if (l.immediateScore < r.immediateScore) return true;
if (l.immediateScore > r.immediateScore) return false;
return false;
}
AICommandEvaluator::AICommandEvaluator(
const AIScoreCalculator& scorer,
const APDCache& apdCache,
BattalionTypeGetter battalionTypeGetter)
: scorer_(scorer),
apdCache_(apdCache),
battalionTypeGetter_(std::move(battalionTypeGetter)) {} // Move the function object
auto AICommandEvaluator::PerformLookahead(
const PlayerId pid,
const bool isDefender,
const int remainingLookahead,
const int maxRepeatCount,
const std::shared_ptr<ShardokEngine>& innerEngine,
const ScoreValue currentUtility,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
// 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);
return p.get_future();
}
const auto nextUtility = currentUtility;
// Check if we've reached the depth limit before making recursive calls
if (remainingLookahead <= 0) {
// Store the current utility in the transposition table and return it
// Note: Store with depth 1 since depth 0 indicates an empty entry in the transposition
// table
g_transpositionTable.store(innerEngine->GetCurrentGameState(), 1, pid, nextUtility);
std::promise<ScoreValue> p;
p.set_value(nextUtility);
return p.get_future();
}
if (const CommandListSPtr nextCommands = innerEngine->GetAvailableCommandsForAIPlayer(pid);
nextCommands && !nextCommands->empty()) {
// Get the future from FindBestCommand without calling .get()
auto bestCommandFuture = FindBestCommand(
pid,
isDefender,
remainingLookahead - 1,
maxRepeatCount,
*innerEngine,
attackerStrategy,
nextUtility,
allCastleCoords,
deadline);
// Return a future that chains the best command evaluation
return std::async(
std::launch::deferred,
[bestCommandFuture = std::move(bestCommandFuture),
innerEngine,
pid,
nextUtility,
remainingLookahead]() mutable -> ScoreValue {
const auto [index, type, lookaheadScore, immediateScore] =
bestCommandFuture.get();
ScoreValue resultScore;
if (auto& nextCommand =
innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
nextCommand->GetCommandType() !=
net::eagle0::shardok::common::END_TURN_COMMAND) {
resultScore = immediateScore;
} else {
resultScore = nextUtility;
}
// Store in transposition table before returning
g_transpositionTable.store(
innerEngine->GetCurrentGameState(),
remainingLookahead,
pid,
resultScore);
return resultScore;
});
}
// No commands available, store and return the current utility as a future
g_transpositionTable
.store(innerEngine->GetCurrentGameState(), remainingLookahead, pid, nextUtility);
std::promise<ScoreValue> p;
p.set_value(nextUtility);
return p.get_future();
}
auto AICommandEvaluator::EvaluateWithRandomness(
const PlayerId pid,
const bool isDefender,
const uint32_t commandIndex,
const int remainingLookahead,
const int maxRepeatCount,
const std::shared_ptr<RandomGenerator>& randomGenerator,
const ShardokEngine& guessedEngine,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> ImmediateAndLookaheadScore {
ImmediateAndLookaheadScore returnValue{};
// 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
returnValue.immediateScore = 0.0;
returnValue.lookaheadScore = p.get_future();
return returnValue;
}
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
auto innerUtility = scorer_.GuessedStateScore(
isDefender,
innerEngine->GetCurrentGameState(),
attackerStrategy,
allCastleCoords);
returnValue.immediateScore = innerUtility;
if (remainingLookahead <= 0) {
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
p.set_value(innerUtility);
} else {
auto lookaheadLambda = [this,
pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
attackerStrategy,
innerUtility,
&allCastleCoords,
deadline]() -> ScoreValue {
auto lookaheadFuture = PerformLookahead(
pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
innerUtility,
attackerStrategy,
allCastleCoords,
deadline);
return lookaheadFuture.get();
};
#if MULTITHREAD
auto launchPolicy = remainingLookahead == 1 ? std::launch::async : std::launch::deferred;
returnValue.lookaheadScore = std::async(launchPolicy, lookaheadLambda);
#else
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
auto lambdaResult = lookaheadLambda();
p.set_value(lambdaResult);
#endif
}
return returnValue;
}
auto AICommandEvaluator::FindBestCommand(
const PlayerId pid,
const bool isDefender,
const int remainingLookahead,
const int maxRepeatCount,
const ShardokEngine& guessedEngine,
const AIStrategy& attackerStrategy,
const ScoreValue currentUtility,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::future<IndexAndScore> {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
// Filter out obviously bad commands to reduce search space
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
guessedDescriptors,
pid,
isDefender,
guessedEngine.GetCurrentGameState(),
apdCache_,
battalionTypeGetter_);
const auto& gameState = guessedEngine.GetCurrentGameState();
// Calculate minimum hex distance to enemies for this player
double minDistToEnemies = std::numeric_limits<double>::max();
const auto* units = gameState->units();
for (size_t i = 0; i < units->size(); ++i) {
if (const auto* playerUnit = units->Get(static_cast<unsigned int>(i));
playerUnit->player_id() == pid) {
const auto& playerCoords = playerUnit->location();
for (size_t j = 0; j < units->size(); ++j) {
if (const auto* enemyUnit = units->Get(static_cast<unsigned int>(j));
enemyUnit->player_id() != pid) {
const auto& enemyCoords = enemyUnit->location();
// Proper hex distance calculation using cube coordinates
const Cube playerCube = OffsetToCube(playerCoords);
const Cube enemyCube = OffsetToCube(enemyCoords);
const int hexDistance = CubeDistance(playerCube, enemyCube);
minDistToEnemies = std::min(minDistToEnemies, static_cast<double>(hexDistance));
}
}
}
}
if (minDistToEnemies == std::numeric_limits<double>::max()) {
minDistToEnemies = 0.0; // No enemies found
}
#if LOGGING_
// Log command count and distance metrics for performance analysis
const auto allCommandCount = guessedDescriptors->size();
const auto filteredCommandCount = filteredIndices.size();
const int currentRound = gameState->current_round();
printf("AI_COMMAND_COUNT: Round %d, Player %d, Defender %d, MinDist %.1f, Commands %zu -> %zu "
"(%.1f%% filtered)\n",
currentRound,
static_cast<int>(pid),
isDefender ? 1 : 0,
minDistToEnemies,
allCommandCount,
filteredCommandCount,
100.0 * (allCommandCount - filteredCommandCount) / allCommandCount);
#endif
const auto commandCount = filteredIndices.size();
// Structure to hold all command evaluation data
struct CommandEvaluation {
size_t index;
CommandType type;
ScoreValue immediateScore;
std::vector<std::future<ScoreValue>> lookaheadFutures;
};
std::vector<CommandEvaluation> commandEvaluations(commandCount);
for (uint32_t index = 0; index < commandCount; index++) {
const auto originalIndex = filteredIndices[index];
const auto& guessedDescriptor = guessedDescriptors->at(originalIndex);
const auto guessedCommandType = guessedDescriptor->GetCommandType();
commandEvaluations[index].index = originalIndex;
commandEvaluations[index].type = guessedCommandType;
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<ScoreValue> p;
commandEvaluations[index].lookaheadFutures.push_back(p.get_future());
p.set_value(currentUtility);
commandEvaluations[index].immediateScore = currentUtility;
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
pid,
isDefender,
originalIndex,
remainingLookahead,
maxRepeatCount,
_averageGenerator,
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
commandEvaluations[index].immediateScore = immediateScore;
commandEvaluations[index].lookaheadFutures.push_back(std::move(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(
pid,
isDefender,
originalIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(
std::vector{1.0 - successChance / 2.0}),
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
// Failure attempt uses the average of (1 - successChance) and 0 as the roll
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
pid,
isDefender,
originalIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(
std::vector{(1.0 - successChance) / 2.0}),
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
commandEvaluations[index].immediateScore =
std::lerp(failureImmediateScore, successImmediateScore, successChance);
auto successSF = successLookaheadScore.share();
auto failureSF = failureLookaheadScore.share();
commandEvaluations[index].lookaheadFutures.push_back(std::async(
std::launch::deferred,
[successSF, failureSF, successChance]() -> double {
return std::lerp(failureSF.get(), successSF.get(), successChance);
}));
} else {
ScoreValue sum = 0.0;
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; 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(
pid,
isDefender,
originalIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(sequence),
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
sum += immediateScore;
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
}
commandEvaluations[index].immediateScore = sum / maxRepeatCount;
}
}
// Return a future that will wait for all evaluations and find the best one
return std::async(
std::launch::deferred,
[evals = std::move(commandEvaluations)]() mutable -> IndexAndScore {
std::vector<IndexAndScore> allResults;
allResults.reserve(evals.size());
// Wait for all futures and compute final scores
for (auto& eval : evals) {
ScoreValue totalLookaheadScore = 0.0;
for (auto& future : eval.lookaheadFutures) {
totalLookaheadScore += future.get();
}
ScoreValue avgLookaheadScore =
eval.lookaheadFutures.empty()
? eval.immediateScore
: totalLookaheadScore / eval.lookaheadFutures.size();
allResults.push_back(IndexAndScore{
.index = eval.index,
.type = eval.type,
.lookaheadScore = avgLookaheadScore,
.immediateScore = eval.immediateScore});
}
// Find the best command using the existing sorter
auto bestIt = std::ranges::max_element(allResults, CommandSorter);
return *bestIt;
});
}
auto AICommandEvaluator::EvaluateCommand(
const PlayerId pid,
const bool isDefender,
const int remainingLookahead,
const int maxRepeatCount,
const ShardokEngine& guessedEngine,
const AIStrategy& attackerStrategy,
const ScoreValue currentUtility,
const CoordsSet& allCastleCoords,
const size_t commandIndex,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
if (commandIndex >= guessedDescriptors->size()) {
std::promise<ScoreValue> p;
p.set_value(currentUtility);
return p.get_future();
}
const auto& guessedDescriptor = guessedDescriptors->at(commandIndex);
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<ScoreValue> p;
p.set_value(currentUtility);
return p.get_future();
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
_averageGenerator,
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
return std::move(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(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(std::vector{1.0 - successChance / 2.0}),
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
// Failure attempt
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(std::vector{(1.0 - successChance) / 2.0}),
guessedEngine,
attackerStrategy,
allCastleCoords,
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);
});
} else {
// For non-deterministic commands without odds, use multiple attempts
std::vector<std::future<ScoreValue>> lookaheadFutures;
lookaheadFutures.reserve(maxRepeatCount);
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
auto sequence = std::vector{
static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1)};
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(sequence),
guessedEngine,
attackerStrategy,
allCastleCoords,
deadline);
lookaheadFutures.push_back(std::move(lookaheadScore));
}
// Return a future that computes the average when needed
return std::async(
std::launch::deferred,
[lookaheadFutures = std::move(lookaheadFutures),
maxRepeatCount]() mutable -> double {
ScoreValue total = 0.0;
for (auto& future : lookaheadFutures) { total += future.get(); }
return total / maxRepeatCount;
});
}
}
} // namespace shardok
@@ -0,0 +1,112 @@
//
// Command evaluator for AI lookahead search.
// Separated from AIScoreCalculator to isolate pure state scoring from lookahead logic.
//
#ifndef EAGLE0_AICOMMANDEVALUATOR_HPP
#define EAGLE0_AICOMMANDEVALUATOR_HPP
#include <chrono>
#include <future>
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
// Forward declarations
class AIScoreCalculator;
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using CommandType = net::eagle0::shardok::common::CommandType;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
/// Evaluates commands with lookahead using minimax-style search.
/// Uses AIScoreCalculator for pure state evaluation, adds recursive lookahead logic.
class AICommandEvaluator {
public:
/// Construct evaluator with a scorer for state evaluation and dependencies for command
/// filtering
AICommandEvaluator(
const AIScoreCalculator& scorer,
const APDCache& apdCache,
BattalionTypeGetter battalionTypeGetter); // Pass by value
/// Evaluates the score for a particular command index with lookahead.
[[nodiscard]] auto EvaluateCommand(
PlayerId pid,
bool isDefender,
int remainingLookahead,
int maxRepeatCount,
const ShardokEngine& guessedEngine,
const AIStrategy& attackerStrategy,
ScoreValue currentUtility,
const CoordsSet& allCastleCoords,
size_t commandIndex,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
/// Find the best command among all available commands at the given depth.
struct IndexAndScore {
size_t index;
CommandType type;
ScoreValue lookaheadScore;
ScoreValue immediateScore;
};
[[nodiscard]] auto FindBestCommand(
PlayerId pid,
bool isDefender,
int remainingLookahead,
int maxRepeatCount,
const ShardokEngine& guessedEngine,
const AIStrategy& attackerStrategy,
ScoreValue currentUtility,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::future<IndexAndScore>;
private:
const AIScoreCalculator& scorer_;
const APDCache& apdCache_;
BattalionTypeGetter battalionTypeGetter_; // Store by value
struct ImmediateAndLookaheadScore {
ScoreValue immediateScore;
std::future<ScoreValue> lookaheadScore;
};
/// Recursive lookahead calculator
[[nodiscard]] auto PerformLookahead(
PlayerId pid,
bool isDefender,
int remainingLookahead,
int maxRepeatCount,
const std::shared_ptr<ShardokEngine>& innerEngine,
ScoreValue currentUtility,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
/// Evaluate single command execution with randomness handling
[[nodiscard]] auto EvaluateWithRandomness(
PlayerId pid,
bool isDefender,
uint32_t commandIndex,
int remainingLookahead,
int maxRepeatCount,
const std::shared_ptr<class RandomGenerator>& randomGenerator,
const ShardokEngine& guessedEngine,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> ImmediateAndLookaheadScore;
};
} // namespace shardok
#endif // EAGLE0_AICOMMANDEVALUATOR_HPP
@@ -36,8 +36,8 @@ std::vector<size_t> AICommandFilter::FilterCommands(
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache) {
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter) {
std::vector<size_t> filteredIndices;
filteredIndices.reserve(commands->size());
@@ -66,8 +66,8 @@ std::vector<size_t> AICommandFilter::FilterCommands(
pid,
isDefender,
gameState,
settings,
apdCache,
battalionTypeGetter,
enemyLocations,
castleLocations,
minDistToEnemies)) {
@@ -80,16 +80,22 @@ std::vector<size_t> AICommandFilter::FilterCommands(
pid,
isDefender,
gameState,
settings,
apdCache,
battalionTypeGetter,
enemyLocations,
minDistToEnemies)) {
shouldFilter = true;
}
// Check strategic blunders
if (!shouldFilter &&
IsStrategicBlunder(*cmd, pid, isDefender, gameState, settings, minDistToEnemies)) {
if (!shouldFilter && IsStrategicBlunder(
*cmd,
pid,
isDefender,
gameState,
apdCache,
battalionTypeGetter,
minDistToEnemies)) {
shouldFilter = true;
}
@@ -104,8 +110,8 @@ bool AICommandFilter::IsWastefulAction(
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
const CoordsSet& enemyLocations,
const CoordsSet& castleLocations,
double minDistToEnemies) {
@@ -269,7 +275,7 @@ bool AICommandFilter::IsWastefulAction(
}
// Get action point distances for this unit's battalion type
const auto& battType = settings.GetBattalionType(actingUnit->battalion().type());
const auto& battType = battalionTypeGetter(actingUnit->battalion().type());
const auto* apd = apdCache->GetRaw(
gameState->hex_map(),
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
@@ -407,8 +413,8 @@ bool AICommandFilter::IsWastefulMovement(
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
const CoordsSet& enemyLocations,
double minDistToEnemies) {
if (cmd.GetCommandType() != CommandType::MOVE_COMMAND) { return false; }
@@ -449,7 +455,7 @@ bool AICommandFilter::IsWastefulMovement(
static_cast<int8_t>(targetCoords.column())};
// Get action point distances for this unit's battalion type
const auto& battType = settings.GetBattalionType(actingUnit->battalion().type());
const auto& battType = battalionTypeGetter(actingUnit->battalion().type());
const auto* apd = apdCache->GetRaw(
gameState->hex_map(),
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
@@ -490,7 +496,8 @@ bool AICommandFilter::IsStrategicBlunder(
PlayerId /*pid*/,
bool /*isDefender*/,
const GameStateW& /*gameState*/,
const SettingsGetter& /*settings*/,
const APDCache& /*apdCache*/,
const BattalionTypeGetter& /*battalionTypeGetter*/,
double /*minDistToEnemies*/) {
// Simplified strategic blunder detection for now
// TODO: Implement proper castle abandonment detection
@@ -8,6 +8,7 @@
#include <memory>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.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/map/CoordsSet.hpp"
@@ -32,8 +33,8 @@ public:
* @param pid Player ID making the move
* @param isDefender True if this player is the defender
* @param gameState Current game state
* @param settings Game settings for parameter lookup
* @param apdCache Action point distance cache for distance calculations
* @param battalionTypeLookup Function to look up battalion types by ID
* @return Filtered list of commands worth evaluating
*/
static std::vector<size_t> FilterCommands(
@@ -41,8 +42,8 @@ public:
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache);
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeLookup);
private:
// Helper to build enemy locations once for efficiency
@@ -54,8 +55,8 @@ private:
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeLookup,
const CoordsSet& enemyLocations,
const CoordsSet& castleLocations,
double minDistToEnemies);
@@ -66,8 +67,8 @@ private:
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeLookup,
const CoordsSet& enemyLocations,
double minDistToEnemies);
@@ -77,7 +78,8 @@ private:
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeLookup,
double minDistToEnemies);
// Helper functions for distance and position analysis
@@ -0,0 +1,23 @@
//
// AICommonTypes.hpp
// Common type definitions used across AI utility functions
//
#ifndef EAGLE0_AICOMMONTYPES_HPP
#define EAGLE0_AICOMMONTYPES_HPP
#include <functional>
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
namespace shardok {
// Function type for looking up battalion types by ID
// Used across AI utilities to get battalion type information without
// needing to pass the entire scorer object
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
} // namespace shardok
#endif // EAGLE0_AICOMMONTYPES_HPP
@@ -0,0 +1,25 @@
//
// AI System Types and Configuration
//
#ifndef EAGLE0_AI_CONFIG_HPP
#define EAGLE0_AI_CONFIG_HPP
namespace shardok {
// Enum for AI algorithm selection
enum class AIAlgorithmType {
ITERATIVE_DEEPENING, // Default: Minimax with sophisticated randomness
MCTS // Monte Carlo Tree Search with multithreading
};
// 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
};
} // namespace shardok
#endif // EAGLE0_AI_CONFIG_HPP
@@ -7,8 +7,10 @@
#include <algorithm>
#include <ranges>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
@@ -19,8 +21,9 @@ constexpr double MINIMUM_RATIO_FOR_DEFENDER_TO_HOLD = 0.60;
auto AIDefenderStrategySelector::BestDefenderStrategy(
const GameStateW& gameState,
const CoordsSet& criticalTileCoords,
int maxRounds,
const APDCache& apdCache,
const SettingsGetter& settings) -> AIStrategy {
const BattalionTypeGetter& battalionTypeGetter) -> AIStrategy {
uint32_t attackerNonUndeadUnitCount = 0;
uint32_t attackerNonUndeadUnitNotRequiringWaterCrossingCount = 0;
int attackerTroops = 0;
@@ -36,7 +39,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
player->player_id(),
criticalTileCoords,
apdCache,
settings);
battalionTypeGetter);
attackerUnitIdsRequiringWaterCrossing.insert(
attackerUnitIdsRequiringWaterCrossing.end(),
unitIdsRequiringWaterCrossing.begin(),
@@ -71,7 +74,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
}
}
const int roundsRemaining = 32 - gameState->current_round();
const int roundsRemaining = maxRounds - gameState->current_round();
AIStrategy chosenStrategy;
// Defender will flee if
@@ -6,19 +6,23 @@
#define EAGLE0_AIDEFENDERSTRATEGYSELECTOR_HPP
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
namespace shardok {
class AIDefenderStrategySelector {
public:
static auto BestDefenderStrategy(
const GameStateW& gameState,
const CoordsSet& criticalTileCoords,
int maxRounds,
const APDCache& apdCache,
const SettingsGetter& settings) -> AIStrategy;
const BattalionTypeGetter& battalionTypeGetter) -> AIStrategy;
};
} // namespace shardok
@@ -49,8 +49,8 @@ auto DefenderDistanceBuf(
const vector<const Unit *> &attackerUnits,
const APDCache &apdCache,
const ALCache &alCache,
const SettingsGetter &settings,
const int braveWaterActionPointCost,
const BattalionTypeGetter &battalionTypeGetter,
ActionPoints braveWaterCost,
const bool lateGame,
const bool includeUndead) -> double {
const auto &locationsToAttackMe = alCache->CachedLocations(defenderLocation, lateGame);
@@ -73,14 +73,14 @@ auto DefenderDistanceBuf(
notBravingDistances[typeInt] = apdCache->GetRaw(
hexMap,
mapId,
settings.GetBattalionType(attacker->battalion().type()),
battalionTypeGetter(attacker->battalion().type()),
false);
bravingDistances[typeInt] = apdCache->GetRaw(
hexMap,
mapId,
settings.GetBattalionType(attacker->battalion().type()),
battalionTypeGetter(attacker->battalion().type()),
true,
braveWaterActionPointCost);
braveWaterCost);
}
}
@@ -6,10 +6,10 @@
#define EAGLE0_AIDISTANCEDEBUF_HPP
#include "AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
@@ -23,8 +23,8 @@ auto DefenderDistanceBuf(
const vector<const Unit *> &attackerUnits,
const APDCache &apdCache,
const ALCache &alCache,
const SettingsGetter &settings,
int braveWaterActionPointCost,
const BattalionTypeGetter &battalionTypeGetter,
ActionPoints braveWaterCost,
bool lateGame,
bool includeUndead) -> double;
@@ -23,7 +23,7 @@ auto AIFleeDecisionCalculator::GetFleeCommandIndex(
auto AIFleeDecisionCalculator::EstimateCombatSuccess(
PlayerId attackerPlayerId,
const GameStateW& gameState,
const SettingsGetter& settings) -> double {
int maxRounds) -> double {
if (gameState->status() == nullptr ||
gameState->status()->state() !=
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING) {
@@ -68,7 +68,7 @@ auto AIFleeDecisionCalculator::EstimateCombatSuccess(
}
}
const int roundsRemaining = settings.Backing().max_rounds() - gameState->current_round();
const int roundsRemaining = maxRounds - gameState->current_round();
// Special case: Attacker has no heroes - automatic loss
if (attackerHeroes == 0) {
@@ -133,18 +133,16 @@ auto AIFleeDecisionCalculator::EstimateCombatSuccess(
auto AIFleeDecisionCalculator::EvaluateFleeVsFight(
PlayerId playerId,
const SettingsGetter& settingsGetter,
const GameStateW& guessedState,
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
int maxRounds,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
bool enableDebugLogging) -> FleeDecision {
// Get flee success odds
const int fleeSuccessChance = fleeCommand->odds().success_chance();
// Get thresholds from settings
const int minimumFleeOddsThreshold = settingsGetter.Backing().ai_minimum_flee_odds_threshold();
const int desperateFleeThreshold = settingsGetter.Backing().ai_desperate_flee_threshold();
if (enableDebugLogging) {
printf("AI FinalRound: Evaluating flee (odds=%d%%)...\n", fleeSuccessChance);
}
@@ -163,7 +161,7 @@ auto AIFleeDecisionCalculator::EvaluateFleeVsFight(
}
// Low flee odds - evaluate if fighting might be better
const double combatWinChance = EstimateCombatSuccess(playerId, guessedState, settingsGetter);
const double combatWinChance = EstimateCombatSuccess(playerId, guessedState, maxRounds);
// If combat situation is hopeless, even bad flee odds are better than certain death
if (combatWinChance <= 0.05 && fleeSuccessChance >= desperateFleeThreshold) {
@@ -215,11 +213,11 @@ auto AIFleeDecisionCalculator::EvaluateFleeVsFight(
auto AIFleeDecisionCalculator::ShouldConsiderFleeing(
PlayerId attackerPlayerId,
const GameStateW& guessedState,
const SettingsGetter& settings,
int maxRounds,
double fleeConsiderationThreshold) -> bool {
// Get combat success probability
const double combatSuccessChance =
EstimateCombatSuccess(attackerPlayerId, guessedState, settings);
EstimateCombatSuccess(attackerPlayerId, guessedState, maxRounds);
// Consider fleeing if combat success chance is below threshold
return combatSuccessChance < fleeConsiderationThreshold;
@@ -10,7 +10,6 @@
#define AIFleeDecisionCalculator_hpp
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
@@ -35,24 +34,26 @@ public:
// Evaluate whether to flee or fight in the final round
[[nodiscard]] static auto EvaluateFleeVsFight(
PlayerId playerId,
const SettingsGetter& settings,
const GameStateW& guessedState,
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
int maxRounds,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
bool enableDebugLogging = false) -> FleeDecision;
// Estimate probability of combat success for the attacker
[[nodiscard]] static auto EstimateCombatSuccess(
PlayerId attackerPlayerId,
const GameStateW& guessedState,
const SettingsGetter& settings) -> double;
int maxRounds) -> double;
// Determine if the attacker should consider fleeing based on combat odds
// Returns true if fleeing should be considered as an option
[[nodiscard]] static auto ShouldConsiderFleeing(
PlayerId attackerPlayerId,
const GameStateW& guessedState,
const SettingsGetter& settings,
int maxRounds,
double fleeConsiderationThreshold = 0.5) -> bool;
private:
@@ -0,0 +1,230 @@
//
// Fast heuristic weighting implementation with context-aware logic
//
#include "AIHeuristicWeighting.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
using CommandType = net::eagle0::shardok::common::CommandType;
using Coords = net::eagle0::shardok::storage::fb::Coords;
using ProtoCoords = net::eagle0::shardok::common::Coords;
double AIHeuristicWeighting::GetCommandWeight(
const net::eagle0::shardok::api::CommandDescriptor& command,
const GameStateW& state,
const CoordsSet& castleCoords,
const APDCache* apdCache,
bool isDefender,
std::function<BattalionTypeSPtr(BattalionTypeId)> getBattalionType) {
// Fast O(1) heuristic weights based on command type and game context
// Higher weight = more likely to select in simulation
// 0.0 = never select (filtered out)
const auto* hexMap = state->hex_map();
const auto* units = state->units();
const auto actorPlayerId = command.player();
switch (command.type()) {
// === HIGH VALUE OFFENSIVE (10.0) ===
// Ranged attacks - very valuable, typically available when in range
case CommandType::ARCHERY_COMMAND: return 20.0;
case CommandType::LIGHTNING_BOLT_COMMAND: return 10.0;
case CommandType::FEAR_COMMAND: return 10.0;
// Area/tactical spells - high impact
case CommandType::METEOR_START_COMMAND: {
// High weight per enemy unit at or adjacent to target
if (!command.has_target()) return 0.0; // Default if no target info
const Coords targetCoords(command.target().row(), command.target().column());
int enemyCount = 0;
// Count enemies at target
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
}
// Count enemies adjacent to target
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
if (const auto* unit = Occupant(units, neighbor.coords)) {
if (unit->player_id() != actorPlayerId) { enemyCount++; }
}
}
return 1.0 + (enemyCount * 15.0); // Base 1 + 15 per enemy in range
}
case CommandType::METEOR_TARGET_COMMAND: {
// High weight per enemy unit at or adjacent to target
if (!command.has_target()) return 6.0; // Default if no target info
const Coords targetCoords(command.target().row(), command.target().column());
int enemyCount = 0;
// Count enemies at target
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
}
// Count enemies adjacent to target
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
if (const auto* unit = Occupant(units, neighbor.coords)) {
if (unit->player_id() != actorPlayerId) { enemyCount++; }
}
}
return 1.0 + (enemyCount * 15.0); // Base 1 + 15 per enemy in range
}
case CommandType::RAISE_DEAD_COMMAND: return 10.0;
case CommandType::HOLY_WAVE_COMMAND: return 8.0;
// Fire on enemy (context-dependent)
case CommandType::START_FIRE_COMMAND: {
// High if enemy at target, low otherwise
if (!command.has_target()) return 3.0; // Default
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - high value
}
}
return 1.0; // No enemy - low value
}
// === MEDIUM-HIGH OFFENSIVE (5.0-7.0) ===
// Direct damage melee
case CommandType::MELEE_COMMAND: return 7.0;
case CommandType::CHARGE_COMMAND: return 7.0; // Damage + movement
case CommandType::CHALLENGE_DUEL_COMMAND: return 5.0;
// Control and tactical magic
case CommandType::CONTROL_COMMAND: return 6.0;
case CommandType::METEOR_CAST_COMMAND: return 6.0; // Finish meteor
case CommandType::REDUCE_COMMAND: {
// High if enemy at target, zero otherwise
if (!command.has_target()) return 0.0;
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - very high value
}
}
return 0.0; // No enemy - don't use
}
// === MOVEMENT - Context-dependent ===
case CommandType::MOVE_COMMAND: {
if (isDefender) {
return 0.0; // Defenders don't move
}
// Attackers: weight based on distance improvement towards castle
if (!command.has_target()) return 4.0; // Default if no target
// Get actor unit to determine battalion type and start position
const auto* actorUnit = units->Get(command.actor().value());
if (!actorUnit) return 4.0; // Default if can't find actor
// Get battalion type for distance calculation
const auto battalionTypeId = actorUnit->battalion().type();
const auto battalionTypePtr = getBattalionType(battalionTypeId);
if (!battalionTypePtr) return 4.0; // Default if can't get battalion type
// Get ActionPointDistances for this battalion type
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
const auto* apd = (*apdCache)->GetRaw(hexMap, mapId, battalionTypePtr, false, -1);
if (!apd) return 4.0; // Default if can't get distances
// Calculate minimum distance from start to any castle
const Coords startCoords = actorUnit->location();
auto minStartDistance = ActionPointDistances::IMPOSSIBLE;
for (const auto& castleCoord : castleCoords) {
const auto dist = apd->Distance(startCoords, castleCoord);
if (dist < minStartDistance) { minStartDistance = dist; }
}
// Calculate minimum distance from end to any castle
const Coords endCoords(command.target().row(), command.target().column());
auto minEndDistance = ActionPointDistances::IMPOSSIBLE;
for (const auto& castleCoord : castleCoords) {
const auto dist = apd->Distance(endCoords, castleCoord);
if (dist < minEndDistance) { minEndDistance = dist; }
}
// Return weight based on distance improvement
// Higher weight if we're moving closer to castle
if (minStartDistance == ActionPointDistances::IMPOSSIBLE ||
minEndDistance == ActionPointDistances::IMPOSSIBLE) {
return 4.0; // Default if distances are impossible
}
const auto improvement = static_cast<double>(minStartDistance - minEndDistance);
return std::max(0.0, improvement);
}
case CommandType::BRAVE_WATER_COMMAND: return 3.0; // Tactical movement
case CommandType::SCOUT_COMMAND:
return 2.0; // Information gathering
// Terrain manipulation
case CommandType::FREEZE_WATER_COMMAND: return 3.0;
case CommandType::BUILD_BRIDGE_COMMAND: return 3.0;
// === LOW VALUE DEFENSIVE/UTILITY (1.0-2.0) ===
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// High if friendly at target, low otherwise
if (!command.has_target()) return 2.0; // Default
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() == actorPlayerId) {
return 8.0; // Friendly at target - high value
}
}
return 1.0; // No friendly - low value
}
case CommandType::UNIT_REST_COMMAND: return 1.5;
case CommandType::FORTIFY_COMMAND: return 2.0;
// Zero weight - don't use in simulation
case CommandType::REPAIR_COMMAND: return 0.0;
case CommandType::HIDE_COMMAND: return 0.0;
case CommandType::RELEASE_UNIT_COMMAND: return 0.0;
case CommandType::REINFORCE_COMMAND: return 10.0;
case CommandType::MANAGE_PRISONER: return 1.0;
// === ZERO WEIGHT - NEVER SELECT (0.0) ===
// Explicitly bad actions
case CommandType::FLEE_COMMAND: return 0.0; // Never flee in simulation
case CommandType::RETREAT_COMMAND: return 0.0;
case CommandType::BECOME_OUTLAW_COMMAND: return 0.0; // Never become outlaw
case CommandType::DISMISS_UNIT_COMMAND:
return 0.0; // Never dismiss in combat
// Actions that are fine as a fallback
case CommandType::END_TURN_COMMAND: return 1.0;
case CommandType::UNIT_STOP_COMMAND: return 1.0;
case CommandType::METEOR_CANCEL_COMMAND: return 1.0;
// Setup commands (shouldn't appear in combat, but filter anyway)
case CommandType::PLACE_UNIT_COMMAND: return 10.0;
case CommandType::PLACE_HIDDEN_UNIT_COMMAND: return 1.0;
case CommandType::END_PLAYER_SETUP_COMMAND: return 1.0;
// Unknown/unhandled
case CommandType::UNKNOWN_COMMAND:
default: return 0.0; // Don't select unknown commands
}
}
} // namespace shardok
@@ -0,0 +1,38 @@
//
// Fast heuristic weighting for MCTS simulations
// Provides O(1) weights based on command type and context
//
#ifndef EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
#define EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
#pragma clang diagnostic pop
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
namespace shardok {
// Fast heuristic-based command weighting for MCTS simulation policy
// Avoids expensive score calculation while maintaining intelligent bias
class AIHeuristicWeighting {
public:
// Get weight for a command using fast heuristics with game context
// Returns weight >= 0.0, where 0.0 means "never select" and higher is more likely
static double GetCommandWeight(
const net::eagle0::shardok::api::CommandDescriptor& command,
const GameStateW& state,
const CoordsSet& castleCoords,
const APDCache* apdCache,
bool isDefender,
std::function<BattalionTypeSPtr(BattalionTypeId)> getBattalionType);
};
} // namespace shardok
#endif // EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
File diff suppressed because it is too large Load Diff
@@ -1,64 +0,0 @@
//
// Created by dancrosby on 3/4/20.
//
#ifndef EAGLE0_AISCORECALCULATOR_HPP
#define EAGLE0_AISCORECALCULATOR_HPP
#include <chrono>
#include <future>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#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"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
namespace shardok {
using net::eagle0::shardok::api::GameStateView;
using GameState = fb::GameState;
using shardok::PlayerId;
using std::future;
using std::vector;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
class AIScoreCalculator {
public:
// Evaluate the score of a guessed game state based on the current AI strategy. DOES NOT perform
// or evaluate any commands.
[[nodiscard]] static auto GuessedStateScore(
bool isDefender,
const GameStateW &state,
const AIStrategy &aiStrategy,
const CoordsSet &allCastleCoords,
const SettingsGetter &settingsGetter,
const APDCache &apdCache,
const ALCache &alCache) -> ScoreValue;
// Evaluates the score for a particular command index for the given player, using lookahead.
[[nodiscard]] static auto CommandScore(
PlayerId pid,
bool isDefender,
int remainingLookahead,
int maxRepeatCount,
const ShardokEngine &guessedEngine,
const AIStrategy &attackerStrategy,
ScoreValue currentUtility,
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
size_t commandIndex,
std::chrono::steady_clock::time_point deadline) -> std::future<ScoreValue>;
};
} // namespace shardok
#endif // EAGLE0_AISCORECALCULATOR_HPP
@@ -3,6 +3,7 @@
//
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
namespace shardok {
AIStrategy FleeStrategy = AIStrategy{AIStrategy::STRATEGY_FLEE};
AIStrategy HoldCastlesStrategy = AIStrategy{AIStrategy::STRATEGY_HOLD_CASTLES};
@@ -24,10 +24,40 @@ int AIEvaluationCounter::GetCurrentCount() { return activeCount.load(); }
auto CalculateTimeBudget(
const PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state) -> AITimeBudget {
const GameStateW &state,
const size_t numCommands) -> AITimeBudget {
const auto settingsGetter = settings->GetGetter();
const auto castleCoords = AllCastleCoords(state->hex_map());
// Check if we're in setup phase
const bool isSetupPhase = state->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
// Get maximum budget cap from settings (in seconds)
const double maxBudgetSeconds =
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
// During setup, use the setup-specific time budget
if (isSetupPhase) {
// Dynamic budget: msPerCommand × numCommands
const double msPerCommand =
settingsGetter.Backing().lookahead_time_budget_per_command_setup_ms();
const double budgetMs = msPerCommand * static_cast<double>(numCommands);
// Clamp to reasonable bounds: 200ms minimum, maxBudgetMs maximum
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
const auto remainingBudget =
std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
return AITimeBudget{
.remainingBudget = remainingBudget,
.minDepthRequired = minDepth,
.isCloseToEnemy = false}; // Not relevant during setup
}
// Determine proximity (≤4 hex distance) - applies to both attackers and defenders
bool isClose = false;
const auto *units = state->units();
@@ -72,12 +102,16 @@ auto CalculateTimeBudget(
}
}
// Get time budget from settings
const auto budget = std::chrono::duration<double>(
isClose ? settingsGetter.Backing().lookahead_time_budget_close_in_seconds()
: settingsGetter.Backing().lookahead_time_budget_far_in_seconds());
// Get time budget from settings - dynamic based on number of commands
// Dynamic budget: msPerCommand × numCommands
const double msPerCommand =
isClose ? settingsGetter.Backing().lookahead_time_budget_per_command_close_ms()
: settingsGetter.Backing().lookahead_time_budget_per_command_far_ms();
const double budgetMs = msPerCommand * static_cast<double>(numCommands);
const auto remainingBudget = std::chrono::duration_cast<std::chrono::milliseconds>(budget);
// Clamp to reasonable bounds: 200ms minimum, maxBudgetMs maximum
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
const auto remainingBudget = std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
// Get minimum depth requirement
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
@@ -36,10 +36,13 @@ struct AITimeBudget {
};
// Calculate time budget based on proximity to enemies and castles
// Time budget is calculated dynamically based on number of available commands:
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
auto CalculateTimeBudget(
PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state) -> AITimeBudget;
const GameStateW &state,
size_t numCommands) -> AITimeBudget;
} // namespace shardok
@@ -335,7 +335,8 @@ auto UnitValue(
const AttackLocations &locationsThisSideCanAttackFrom,
const CoordsSet &locationsInDangerFromEnemy,
const ActionPointDistances *distances,
const SettingsGetter &settings) -> ScoreValue {
int meteorRange,
double meteorCastVigorCost) -> ScoreValue {
const auto &location = unit->location();
if (location.row() < 0) return 0; // unplaced unit
@@ -380,8 +381,8 @@ auto UnitValue(
roundsRemaining,
attackerUnits,
defenderUnits,
settings.Backing().meteor_range(),
settings.Backing().meteor_cast_vigor_cost());
meteorRange,
meteorCastVigorCost);
// scouting values
// attack range
@@ -46,7 +46,8 @@ auto UnitValue(
const AttackLocations &locationsThisSideCanAttackFrom,
const CoordsSet &locationsInDangerFromEnemy,
const ActionPointDistances *distances,
const SettingsGetter &settings) -> ScoreValue;
int meteorRange,
double meteorCastVigorCost) -> ScoreValue;
} // namespace shardok
@@ -15,7 +15,7 @@ auto UnitIdsRequiringWaterCrossing(
const PlayerId pid,
const CoordsSet &destinations,
const APDCache &apdCache,
const SettingsGetter &settings) -> vector<UnitId> {
const BattalionTypeGetter &battalionTypeGetter) -> vector<UnitId> {
// Put out all the fires, except on bridges
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
@@ -36,7 +36,7 @@ auto UnitIdsRequiringWaterCrossing(
for (const auto *unit : *gameState->units()) {
if (unit->player_id() != pid) continue;
const auto &battType = settings.GetBattalionType(unit->battalion().type());
const auto &battType = battalionTypeGetter(unit->battalion().type());
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
for (const Coords &destination : destinations) {
@@ -76,8 +76,7 @@ auto UnitIdsRequiringWaterCrossing(
auto UnitIdsToCreateWaterCrossing(
const GameStateW &gameState,
const PlayerId pid,
const APDCache & /*apdCache*/,
const SettingsGetter &settings) -> vector<UnitId> {
const BattalionTypeGetter &battalionTypeGetter) -> vector<UnitId> {
vector<UnitId> unitIds{};
for (const auto *unit : *gameState->units()) {
@@ -88,7 +87,7 @@ auto UnitIdsToCreateWaterCrossing(
if (!unit->has_attached_hero()) continue;
const auto profession = unit->attached_hero().profession_info().profession();
const auto &battalionType = settings.GetBattalionType(unit->battalion().type());
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
if (profession == net::eagle0::shardok::storage::fb::Profession_ENGINEER ||
(profession == net::eagle0::shardok::storage::fb::Profession_MAGE &&
@@ -199,14 +198,14 @@ auto IntendedCrossingStarts(
const GameStateW &gameState,
const vector<UnitId> &unitIdsCreatingCrossing,
const CoordsSet &tilesToStartCrossingFrom,
const MapId &mapId,
const APDCache &apdCache,
const SettingsGetter &settings) -> CoordsSet {
const BattalionTypeGetter &battalionTypeGetter) -> CoordsSet {
CoordsSet intendedCrossingStarts(gameState->hex_map());
const MapId mapId = apdCache->GetMapId(gameState->hex_map());
for (const UnitId uid : unitIdsCreatingCrossing) {
const Unit *unit = gameState->units()->Get(uid);
const Coords &location = unit->location();
const auto &battalionType = settings.GetBattalionType(unit->battalion().type());
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
const auto *apd = apdCache->GetRaw(gameState->hex_map(), mapId, battalionType, false);
if (location.row() >= 0) {
@@ -219,4 +218,111 @@ auto IntendedCrossingStarts(
return intendedCrossingStarts;
}
using Unit = net::eagle0::shardok::storage::fb::Unit;
constexpr double kNoRequiredCrossingScore = std::numeric_limits<double>::max();
constexpr double kNoCrossingCreatorsScore = std::numeric_limits<double>::min();
auto WaterCrossingScore(
const PlayerId playerId,
const BattalionTypeGetter &battalionTypeGetter,
const GameStateW &gameState,
const CoordsSet &castleCoords,
const CoordsSet &startCrossingFrom,
const APDCache &apdCache) -> double {
uint32_t castleClaimCount = 0;
for (const auto *unit : *gameState->units()) {
if (unit->player_id() != playerId) continue;
const auto status = unit->status();
if (status != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
status != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT)
continue;
if (!unit->has_attached_hero()) continue;
++castleClaimCount;
}
CoordsSet destinations = castleCoords;
if (castleClaimCount < castleCoords.size()) {
destinations = CoordsSet(gameState->hex_map());
for (const auto *enemyUnit : *gameState->units()) {
if (enemyUnit->player_id() == playerId) continue;
const auto status = enemyUnit->status();
if (status != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
AssertValid(enemyUnit->location(), gameState->hex_map());
destinations.Add(enemyUnit->location());
}
}
const auto unitIdsRequiringCrossing = UnitIdsRequiringWaterCrossing(
gameState,
playerId,
castleCoords,
apdCache,
battalionTypeGetter);
if (unitIdsRequiringCrossing.empty()) return kNoRequiredCrossingScore;
const auto unitIdsCreatingCrossing =
UnitIdsToCreateWaterCrossing(gameState, playerId, battalionTypeGetter);
if (unitIdsCreatingCrossing.empty()) return kNoCrossingCreatorsScore;
double totalScore = 0;
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
// First put a big penalty on the distance for units that can create a crossing
for (const UnitId uid : unitIdsCreatingCrossing) {
const Unit *unit = gameState->units()->Get(uid);
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
Coords location = unit->location();
int thisDistance;
if (location.row() < 0) thisDistance = 1000;
else {
const auto *apd = apdCache->GetRaw(gameState->hex_map(), mapId, battalionType, false);
thisDistance = MinimumDistance(apd, location, startCrossingFrom);
}
totalScore -= thisDistance * 100.0;
}
// Now a smaller penalty for distance for units that need to cross, except if they block -- then
// a large penalty
for (const UnitId uid : unitIdsRequiringCrossing) {
// If this unit ID can also create a crossing, we already handled it
if (std::ranges::contains(unitIdsCreatingCrossing, uid)) continue;
const Unit *unit = gameState->units()->Get(uid);
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
Coords location = unit->location();
const auto *apd = apdCache->GetRaw(gameState->hex_map(), mapId, battalionType, false);
int thisDistance;
if (location.row() < 0) thisDistance = 1000;
else { thisDistance = MinimumDistance(apd, location, startCrossingFrom); }
bool targetBlocks = false;
// If we're not capable of creating a crossing, don't get in the way of somebody that is.
for (const UnitId crossingUid : unitIdsCreatingCrossing) {
const auto *crossingCapableUnit = gameState->units()->Get(crossingUid);
// Don't check for units that aren't yet placed
if (crossingCapableUnit->location().row() < 0) continue;
AssertValid(crossingCapableUnit->location(), gameState->hex_map());
if (thisDistance <
MinimumDistance(apd, crossingCapableUnit->location(), startCrossingFrom)) {
targetBlocks = true;
break;
}
}
if (targetBlocks) continue;
totalScore -= thisDistance;
}
return totalScore;
}
} // namespace shardok
@@ -5,6 +5,7 @@
#ifndef EAGLE0_AIWATERCROSSINGCALCULATOR_HPP
#define EAGLE0_AIWATERCROSSINGCALCULATOR_HPP
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
@@ -34,14 +35,13 @@ auto UnitIdsRequiringWaterCrossing(
PlayerId pid,
const CoordsSet& destinations,
const APDCache& apdCache,
const SettingsGetter& settings) -> vector<UnitId>;
const BattalionTypeGetter& battalionTypeGetter) -> vector<UnitId>;
// Units belonging to the player that are capable of creating water crossings
auto UnitIdsToCreateWaterCrossing(
const GameStateW& gameState,
PlayerId pid,
const APDCache& apdCache,
const SettingsGetter& settings) -> vector<UnitId>;
const BattalionTypeGetter& battalionTypeGetter) -> vector<UnitId>;
// Whether a unit of the given type can reach destination from origin, given the current state
// of the map
@@ -71,9 +71,17 @@ auto IntendedCrossingStarts(
const GameStateW& gameState,
const vector<UnitId>& unitIdsCreatingCrossing,
const CoordsSet& tilesToStartCrossingFrom,
const MapId& mapId,
const APDCache& apdCache,
const SettingsGetter& settings) -> CoordsSet;
const BattalionTypeGetter& battalionTypeGetter) -> CoordsSet;
// Calculate score based on water crossing strategy
auto WaterCrossingScore(
PlayerId playerId,
const BattalionTypeGetter& battalionTypeGetter,
const GameStateW& gameState,
const CoordsSet& castleCoords,
const CoordsSet& startCrossingFrom,
const APDCache& apdCache) -> double;
} // namespace shardok
@@ -18,7 +18,7 @@ constexpr ScoreValue kNoRequiredCrossingScore = std::numeric_limits<ScoreValue>:
constexpr ScoreValue kNoCrossingCreatorsScore = std::numeric_limits<ScoreValue>::min();
[[nodiscard]] auto AIWaterCrossingCommandChooser::WaterCrossingScore(
const SettingsGetter &settingsGetter,
const BattalionTypeGetter &battalionTypeGetter,
const GameStateW &gameState,
const CoordsSet &castleCoords,
const CoordsSet &startCrossingFrom) const -> ScoreValue {
@@ -51,15 +51,13 @@ constexpr ScoreValue kNoCrossingCreatorsScore = std::numeric_limits<ScoreValue>:
playerId,
castleCoords,
apdCache,
settingsGetter);
battalionTypeGetter);
if (unitIdsRequiringCrossing.empty()) return kNoRequiredCrossingScore;
const auto unitIdsCreatingCrossing =
UnitIdsToCreateWaterCrossing(gameState, playerId, apdCache, settingsGetter);
UnitIdsToCreateWaterCrossing(gameState, playerId, battalionTypeGetter);
if (unitIdsCreatingCrossing.empty()) return kNoCrossingCreatorsScore;
fprintf(stderr, "%lu units require a water crossing\n", unitIdsRequiringCrossing.size());
ScoreValue totalScore = 0;
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
@@ -67,7 +65,7 @@ constexpr ScoreValue kNoCrossingCreatorsScore = std::numeric_limits<ScoreValue>:
// First put a big penalty on the distance for units that can create a crossing
for (const UnitId uid : unitIdsCreatingCrossing) {
const Unit *unit = gameState->units()->Get(uid);
const auto &battalionType = settingsGetter.GetBattalionType(unit->battalion().type());
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
Coords location = unit->location();
int thisDistance;
@@ -88,7 +86,7 @@ constexpr ScoreValue kNoCrossingCreatorsScore = std::numeric_limits<ScoreValue>:
if (std::ranges::contains(unitIdsCreatingCrossing, uid)) continue;
const Unit *unit = gameState->units()->Get(uid);
const auto &battalionType = settingsGetter.GetBattalionType(unit->battalion().type());
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
Coords location = unit->location();
const auto *apd = apdCache->GetRaw(gameState->hex_map(), mapId, battalionType, false);
@@ -120,7 +118,7 @@ constexpr ScoreValue kNoCrossingCreatorsScore = std::numeric_limits<ScoreValue>:
}
auto AIWaterCrossingCommandChooser::StartCrossingFrom(
const SettingsGetter &settingsGetter,
const BattalionTypeGetter &battalionTypeGetter,
const GameStateW &gameState,
const CoordsSet &castleCoords) const -> CoordsSet {
CoordsSet startCrossingFrom(gameState->hex_map());
@@ -154,16 +152,16 @@ auto AIWaterCrossingCommandChooser::StartCrossingFrom(
playerId,
castleCoords,
apdCache,
settingsGetter);
battalionTypeGetter);
if (unitIdsRequiringCrossing.empty()) return startCrossingFrom;
const auto unitIdsCreatingCrossing =
UnitIdsToCreateWaterCrossing(gameState, playerId, apdCache, settingsGetter);
UnitIdsToCreateWaterCrossing(gameState, playerId, battalionTypeGetter);
if (unitIdsCreatingCrossing.empty()) return startCrossingFrom;
for (const UnitId uid : unitIdsRequiringCrossing) {
const Unit *unit = gameState->units()->Get(uid);
const auto &battalionType = settingsGetter.GetBattalionType(unit->battalion().type());
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
Coords origin = unit->location();
// FIXME: this is just grabbing the first starting position, ideally we'd try them all
@@ -6,11 +6,10 @@
#define EAGLE0_AIWATERCROSSINGCOMMANDCHOOSER_HPP
#include <utility>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/FlatbufferWrapper.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
@@ -33,13 +32,13 @@ public:
: playerId(pid),
apdCache(std::move(apdCache)) {}
auto StartCrossingFrom(
const SettingsGetter &settingsGetter,
[[nodiscard]] auto StartCrossingFrom(
const BattalionTypeGetter &battalionTypeGetter,
const GameStateW &gameState,
const CoordsSet &castleCoords) const -> CoordsSet;
[[nodiscard]] auto WaterCrossingScore(
const SettingsGetter &settingsGetter,
const BattalionTypeGetter &battalionTypeGetter,
const GameStateW &gameState,
const CoordsSet &castleCoords,
const CoordsSet &startCrossingFrom) const -> ScoreValue;
@@ -210,4 +210,556 @@ Where:
- **Magnitude**: Indicates confidence/importance of the evaluation
- **Relative scoring**: Only score differences matter, not absolute values
This scoring system provides a robust framework for tactical AI decision-making, balancing immediate tactical gains with strategic objectives while handling the uncertainty inherent in combat outcomes.
This scoring system provides a robust framework for tactical AI decision-making, balancing immediate tactical gains with strategic objectives while handling the uncertainty inherent in combat outcomes.
## AIScoreCalculator Function Reference
### Public Interface Functions
#### `GuessedStateScore`
**Purpose**: Evaluates the score of a game state from the perspective of the current AI strategy without performing any commands.
**Parameters**:
- `isDefender`: Whether the AI is playing as defender
- `state`: Current game state to evaluate
- `aiStrategy`: Strategy being used (attack castles, hold castles, scatter, etc.)
- `allCastleCoords`: Set of all castle coordinates on the map
- `settingsGetter`: Game configuration and rules
- `apdCache`: Cached action point distances for movement calculations
- `alCache`: Cached attack locations for combat calculations
**Returns**: Score value representing how favorable the state is for the evaluating player (positive = good, negative = bad)
#### `CommandScore`
**Purpose**: Evaluates the score for a specific command using lookahead search to consider future consequences.
**Parameters**:
- `pid`: Player ID executing the command
- `isDefender`: Whether the player is a defender
- `remainingLookahead`: Depth of recursive search remaining
- `maxRepeatCount`: Number of random simulations for non-deterministic commands
- `guessedEngine`: Current game engine state
- `attackerStrategy`: Strategy being used by attackers
- `currentUtility`: Current game state score before command execution
- `settingsGetter`: Game configuration
- `allCastleCoords`: Castle locations
- `apdCache` & `alCache`: Cached distance/attack calculations
- `commandIndex`: Index of command to evaluate
- `deadline`: Time limit for computation
**Returns**: Future containing the final score after lookahead evaluation
### Internal Core Functions
#### `BuildDecisionTree` (NEW)
**Purpose**: Builds a complete decision tree containing all evaluated command paths up to the specified depth.
**Process**:
1. Filters commands using `AICommandFilter` to reduce search space
2. For each command, calls `ExecuteCommandForTree` to build complete subtrees
3. Returns full tree with all possible moves and their consequences
4. Identifies best command within the complete tree structure
**Returns**: `std::future<CommandDecisionTree>` containing the complete decision tree
#### `BestCommandIndex` (Legacy - Wrapper)
**Purpose**: Backward compatibility wrapper that uses `BuildDecisionTree` but returns traditional `IndexAndScore`.
**Process**:
1. Calls `BuildDecisionTree` to get complete tree
2. Extracts best command information for compatibility
3. Returns only the optimal command details in legacy format
#### `ExecuteCommandForTree` (NEW)
**Purpose**: Executes a command and creates a tree node with the resulting game state and scores.
**Process**:
1. Creates engine copy and executes the command with given random seed
2. Creates `CommandTreeNode` with command results and game state
3. Calculates immediate score using `GuessedStateScore`
4. Calls `RecursiveTreeBuilder` to populate child nodes if depth allows
5. Calculates lookahead score from children (or uses immediate score)
**Returns**: `std::unique_ptr<CommandTreeNode>` containing the command execution results and subtree
#### `RecursiveTreeBuilder` (NEW)
**Purpose**: Recursively populates child nodes of a tree node by building subtrees for subsequent moves.
**Process**:
1. Gets available commands for the next player
2. Filters commands to reduce search space
3. For each command, calls `ExecuteCommandForTree` to create child nodes
4. Handles different command types (deterministic, odds-based, random)
5. Populates the parent node's children vector with complete subtrees
#### `CalcOne` (Legacy)
**Purpose**: Executes a single command simulation with specified randomness and returns both immediate and lookahead scores.
**Process**:
1. Creates engine copy and executes the command with given random seed
2. Calculates immediate score using `GuessedStateScore`
3. Initiates recursive lookahead calculation if depth remains
4. Handles timeouts gracefully by returning default scores
#### `EvaluateCommand`
**Purpose**: Lower-level command evaluation that handles different command types appropriately.
**Command Type Handling**:
- **Deterministic**: Single evaluation with average randomness (0.5)
- **Odds-based**: Two evaluations (success/failure) weighted by success probability
- **Non-deterministic**: Multiple evaluations with distributed random values, averaged
#### `BasicLookaheadCalculator`
**Purpose**: Recursive lookahead search that finds the best future command sequence and propagates scores backward.
**Features**:
- Uses transposition table to cache previously computed positions
- Handles depth limits and terminal states
- Returns futures for asynchronous computation
- Stores results in transposition table for reuse
### Strategy-Specific Scoring Functions
#### `AttackerScoreForState`
**Purpose**: Calculates state score from attacker perspective based on strategy type.
**Strategy Support**:
- `STRATEGY_ATTACK_CASTLES`: Prioritizes capturing castle positions
- `STRATEGY_ATTACK_UNITS`: Focuses on eliminating defender units
- `STRATEGY_HOLD_CASTLES`: Maintains control of captured castles
- `STRATEGY_CROSS_RIVERS`: Special water crossing objectives
- `STRATEGY_FLEE`: Escape-focused scoring
#### `DefenderScoreForState`
**Purpose**: Calculates state score from defender perspective.
**Strategy Support**:
- `STRATEGY_HOLD_CASTLES`: Defend critical castle positions
- `STRATEGY_SCATTER`: Spread units to avoid elimination
- `STRATEGY_FLEE`: Escape-focused scoring
#### `AttackerUnitsScore`
**Purpose**: Core unit valuation function that calculates total value of all units on the board with contextual modifiers.
**Features**:
- Uses `UnitValue` for individual unit calculations
- Applies distance multipliers based on proximity to objectives
- Handles special cases like undead, VIP units, and scattered defenders
- Incorporates castle bonuses and environmental penalties
### Specialized Strategy Functions
#### `DefenderScatterStrategyScoreForState`
**Purpose**: Implements scatter strategy scoring that rewards defensive units for staying far from enemies and friendlies.
#### `DefenderHoldCastlesStrategyScoreForState`
**Purpose**: Implements castle defense strategy with victory condition scoring.
#### `FleeStrategyScoreForState`
**Purpose**: Implements flee strategy that heavily penalizes remaining on the battlefield.
### Utility Functions
#### `AttackerMultiplierForTargetDistance`
**Purpose**: Calculates distance-based scoring multipliers for attackers based on proximity to priority targets.
**Features**:
- Uses recursive priority list evaluation
- Accounts for occupied vs. unoccupied targets
- Incorporates brave water crossing capabilities
- Uses cached action point distances for efficiency
#### `CommandSorter`
**Purpose**: Comparison function for ranking commands by lookahead score (primary) and immediate score (tiebreaker).
#### `IsDeterministic`
**Purpose**: Determines if a command type has predictable outcomes or requires random simulation.
### Performance and Caching
#### `EffectiveDistanceCache`
**Purpose**: Memoization cache for expensive distance calculations between units and targets.
#### `AttackerScorePerformanceLogger`
**Purpose**: Performance monitoring system that tracks call frequency and timing for `AttackerScoreForState`.
The function architecture supports parallel evaluation, caching, and recursive lookahead while maintaining separation between strategy-specific logic and core evaluation mechanics.
## Decision Tree Data Structures (NEW)
### CommandTreeNode
**Purpose**: Represents a single command execution and its consequences in the decision tree.
**Key Fields**:
- `commandIndex`: Index of the command in the original command list
- `commandType`: Type of command (MOVE, MELEE, END_TURN, etc.)
- `immediateScore`: Score of the game state immediately after this command
- `lookaheadScore`: Best achievable score considering future moves
- `resultingGameState`: Game state after command execution
- `children`: Vector of child nodes representing subsequent possible moves
- `playerId`, `depth`, `isDefender`: Metadata about the command context
**Features**:
- Stores complete game state for each decision point
- Maintains parent-child relationships for tree traversal
- Supports both immediate and lookahead scoring
- Contains metadata for debugging and analysis
### CommandDecisionTree
**Purpose**: Complete decision tree containing all evaluated command paths from a given position.
**Key Fields**:
- `rootNodes`: All possible first moves from the starting position
- `bestCommand`: Pointer to the optimal root command
- `maxDepth`: Maximum lookahead depth of the tree
- `totalNodes`: Total number of nodes in the tree (for statistics)
**Features**:
- Provides complete visibility into AI decision-making process
- Enables analysis of alternative moves and their consequences
- Supports tree statistics and debugging information
- Maintains backward compatibility through `GetBestCommandIndex()`
**Memory Management**:
- Uses `std::unique_ptr` for automatic memory cleanup
- `GameStateW` objects are stored directly (not shared pointers for simplicity)
- Tree structure ensures proper cleanup when nodes go out of scope
### Tree vs. Legacy Approach Comparison
| Aspect | Legacy (Single Best) | Tree-Based (Complete) |
|--------|---------------------|----------------------|
| **Output** | Best command only | Complete decision tree |
| **Memory** | Minimal | Higher (stores all paths) |
| **Analysis** | Limited visibility | Full decision transparency |
| **Debugging** | Single command info | Complete move sequences |
| **Performance** | Slightly faster | Comparable (same calculations) |
| **Compatibility** | Direct usage | Wrapper maintains compatibility |
### Usage Patterns
**For AI Decision Making**:
```cpp
auto treeFuture = BuildDecisionTree(pid, isDefender, depth, maxRepeat,
engine, strategy, utility, settings,
castles, apdCache, alCache, deadline);
CommandDecisionTree tree = treeFuture.get();
size_t bestCommand = tree.bestCommand->commandIndex;
```
**For Analysis and Debugging**:
```cpp
CommandDecisionTree tree = treeFuture.get();
// Examine all possible moves
for (const auto& rootNode : tree.rootNodes) {
std::cout << "Command " << rootNode->commandIndex
<< " Score: " << rootNode->lookaheadScore << std::endl;
// Traverse children to see consequences
for (const auto& child : rootNode->children) {
// ... analyze child moves
}
}
```
**Legacy Compatibility**:
```cpp
// Existing code continues to work unchanged
auto indexScoreFuture = BestCommandIndex(pid, isDefender, ...);
IndexAndScore result = indexScoreFuture.get();
size_t bestCommand = result.index;
```
The tree-based approach provides complete decision transparency while maintaining full backward compatibility with existing AI code.
## MCTS Alternative: Randomness Handling Recommendations
The new MCTS-based AI system is available in `MCTSAI.hpp/.cpp` and provides an alternative to the iterative deepening approach. However, the current MCTS implementation uses simplified randomness handling compared to the sophisticated approach in the original system.
### Current MCTS Limitations
1. **Expansion Phase**: Uses average rolls (0.5) for all commands during tree expansion
2. **Simulation Phase**: Uses random command selection with average rolls
3. **Missing**: No explicit chance nodes for commands with `HasOdds()`
4. **Missing**: No multi-sample evaluation for stochastic commands
### Recommended Improvements: Chance Node Integration
#### 1. **Explicit Chance Nodes** (Highest Priority)
For commands with `HasOdds()`, create explicit chance nodes in the MCTS tree:
```cpp
// During MCTSExpansion
if (descriptor->HasOdds()) {
// Create TWO child nodes: success and failure
auto successNode = CreateMCTSNode(commandIndex, SUCCESS_VARIANT);
auto failureNode = CreateMCTSNode(commandIndex, FAILURE_VARIANT);
// Execute with deterministic rolls (matching original system)
ExecuteWithRoll(successNode, 1.0 - successChance/2.0); // High roll
ExecuteWithRoll(failureNode, (1.0 - successChance)/2.0); // Low roll
// Set probability weights for selection
successNode->probabilityWeight = successChance;
failureNode->probabilityWeight = 1.0 - successChance;
}
```
#### 2. **Weighted Selection for Chance Nodes**
Modify `MCTSSelection` to handle chance nodes:
```cpp
if (node->isChanceNode) {
// Select based on probability distribution, not UCB1
return SelectByProbability(node->children);
} else {
// Normal UCB1 selection for decision nodes
return node->GetBestChild(explorationConstant);
}
```
#### 3. **Probability-Weighted Backpropagation**
Update backpropagation to account for chance node probabilities:
```cpp
void MCTSBackpropagation(MCTSNode* node, double reward) {
while (node) {
node->visitCount++;
// Weight reward by probability for chance nodes
double weightedReward = reward;
if (node->parent && node->parent->isChanceNode) {
weightedReward *= node->probabilityWeight;
}
node->totalReward += weightedReward;
node->averageReward = node->totalReward / node->visitCount;
node = node->parent;
}
}
```
#### 4. **Multi-Sample Commands**
For commands without explicit odds but with randomness, use stratified sampling:
```cpp
// During expansion, create multiple child nodes with different rolls
for (int sample = 0; sample < numSamples; ++sample) {
double roll = static_cast<double>(sample) / (numSamples - 1);
auto sampleNode = CreateMCTSNodeWithRoll(commandIndex, roll);
sampleNode->probabilityWeight = 1.0 / numSamples;
}
```
### Benefits of Chance Node Integration
1. **Accurate Evaluation**: Preserves the sophisticated randomness handling from the original system
2. **Better Convergence**: MCTS can properly explore both success/failure outcomes
3. **Realistic Simulations**: Tree accurately represents game's probability distributions
4. **Comparable Results**: Makes MCTS results directly comparable to iterative deepening
### Implementation Priority
1. **Phase 1**: Add explicit chance nodes for `HasOdds()` commands
2. **Phase 2**: Implement probability-weighted selection and backpropagation
3. **Phase 3**: Add multi-sample support for general stochastic commands
4. **Phase 4**: Optimize performance with lazy expansion of chance nodes
### Alternative: Determinization Approach
If explicit chance nodes prove too complex, consider **determinization**:
- Run multiple MCTS trees with different fixed random seeds
- Aggregate results across all determinizations
- Simpler to implement but potentially less accurate than explicit chance nodes
### Switching Between AI Systems
Both AI systems (`IterativeDeepeningAI` and `MCTSAI`) implement compatible interfaces. The algorithm is selected at **runtime** via the ShardokAIClient constructor:
```cpp
// Using Iterative Deepening (default)
ShardokAIClient client(playerId, isDefender, hexMap, settings);
// Or explicitly:
ShardokAIClient client(playerId, isDefender, hexMap, settings,
AIAlgorithmType::ITERATIVE_DEEPENING);
// Using MCTS
ShardokAIClient client(playerId, isDefender, hexMap, settings,
AIAlgorithmType::MCTS);
// Note: MCTS configuration can be customized via MCTSConfig:
// - maxIterations: 10000 (max MCTS iterations per move)
// - maxSimulationDepth: 10 (depth for rollout phase)
// - maxTreeDepth: 20 (max tree depth to prevent stack overflow)
// - explorationConstant: 1.414 (UCB1 exploration vs exploitation)
// - useMultithreading: true (APD cache is thread-safe with TLS + mutex protection)
// - numThreads: 4
```
The selection is made per AI client instance, allowing different algorithms for different players or game situations within the same server process.
#### Direct AI Usage (Lower Level)
Both AI systems can also be used directly:
```cpp
// Using Iterative Deepening directly
auto iterativeAI = IterativeDeepeningAI(playerId, isDefender, strategy,
castleCoords, apdCache, alCache);
auto result = iterativeAI.IterativeSearch(settings, state, commands, budget);
// Using MCTS directly
auto mctsAI = MCTSAI(playerId, isDefender, strategy,
castleCoords, apdCache, alCache);
auto result = mctsAI.Search(settings, state, commands, budget);
```
#### Algorithm Comparison
| Feature | Iterative Deepening | MCTS |
|---------|-------------------|------|
| **Randomness Handling** | Sophisticated (chance nodes, multi-sample) | Simplified (average rolls) |
| **Performance** | Single-threaded | Multithreaded |
| **Search Type** | Fixed depth with iterative deepening | Adaptive with time budget |
| **Memory Usage** | Lower | Higher (maintains tree) |
| **Max Tree Depth** | Limited by lookahead setting | Limited by `maxTreeDepth` config (default: 20) |
| **Tree Destruction** | Not applicable | Iterative (avoids stack overflow) |
| **Best For** | Precise evaluation, production | Performance testing, fast decisions |
The MCTS implementation provides a solid foundation. Known limitations:
1. **Randomness Handling**: Simplified compared to iterative deepening (no explicit chance nodes)
2. **Simulation Quality**: Uses random rollouts instead of sophisticated evaluation
Note: The APD cache is fully thread-safe using thread-local storage and mutex-protected shared cache.
Adding chance node handling and ensuring thread safety would make it a superior replacement for the iterative deepening approach while maintaining the sophisticated randomness evaluation that makes the current system effective.
## MCTS Configuration Options
The MCTS AI system provides extensive configuration through the `MCTSConfig` structure:
### Core MCTS Parameters
```cpp
struct MCTSConfig {
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
int maxSimulationDepth = 1000; // Maximum depth for rollout
int maxTreeDepth = 2000; // Maximum tree depth to prevent stack overflow
bool useMultithreading = true; // Enable parallel MCTS
int numThreads = 16; // Number of threads for parallel MCTS (when enabled)
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
bool enableTranspositionDetection = true; // Enable pruning of duplicate states
double immediateScoreTieBreakThreshold = 5.0; // When avg rewards differ by less than this, prefer higher immediate score
double visitCountTolerance = 0.05; // Treat visit counts as equal if within this % of best count
bool enableImmediateScoreInUCB1 = true; // Apply immediate score tie-breaking in UCB1 selection too
};
```
### Exploration vs Exploitation
- **`explorationConstant`**: Controls the exploration vs exploitation balance in UCB1 selection
- Higher values (>1.414): More exploration of unvisited nodes
- Lower values (<1.414): More exploitation of known good moves
- Default: 1.414 (√2, theoretical optimum for UCB1)
### Tree Structure Limits
- **`maxTreeDepth`**: Prevents stack overflow in deep game trees
- Default: 2000 (very high limit for most tactical scenarios)
- Terminal detection stops expansion when this depth is reached
- **`maxSimulationDepth`**: Controls rollout length during simulation phase
- Default: 1000 (sufficient for most tactical scenarios)
- Longer simulations provide more accurate estimates but use more time
### Multithreading Configuration
- **`useMultithreading`**: Enable/disable parallel MCTS execution
- Default: true (takes advantage of modern multi-core CPUs)
- Requires thread-safe game engine and scoring components
- **`numThreads`**: Number of worker threads for parallel tree building
- Default: 16 (adjust based on available CPU cores)
- More threads can improve search speed but with diminishing returns
### Simulation Policies
The `MCTSSimulationPolicy` enum controls how commands are selected during the rollout phase:
- **`RANDOM`**: Pure random selection from all available commands
- Fastest but least informed simulations
- Good baseline for testing MCTS convergence
- **`FILTERED_RANDOM`**: Random selection from AICommandFilter-approved commands
- Eliminates obviously bad moves (moving away from objectives, etc.)
- Better simulation quality with minimal overhead
- **`BEST_IMMEDIATE`**: Always choose command with highest immediate score
- Most informed simulations
- Slower but higher quality rollouts
- Default setting for production use
- **`WEIGHTED_BEST_IMMEDIATE`**: Random selection weighted by immediate score ranking
- Balances exploration with informed choice
- Alternative to pure greedy selection
### Transposition Detection
- **`enableTranspositionDetection`**: Enable pruning of duplicate game states
- Default: true (improves search efficiency)
- Uses hash-based state identification
- Prevents wasted computation on equivalent positions reached via different move sequences
### Immediate Score Tie-Breaking
These settings address MCTS's tendency to choose indirect paths when direct paths lead to the same outcome:
- **`immediateScoreTieBreakThreshold`**: Score difference threshold for tie-breaking
- Default: 5.0 (when backpropagated rewards differ by less than this, prefer immediate score)
- Helps AI choose direct moves over equivalent indirect sequences
- Improves user experience by reducing unnecessary intermediate moves
- **`visitCountTolerance`**: Visit count equality threshold for tie-breaking
- Default: 0.05 (5% tolerance - visit counts within this percentage are considered equal)
- Prevents minor visit count differences from overriding immediate score preferences
- **`enableImmediateScoreInUCB1`**: Apply immediate score tie-breaking during exploration
- Default: true (consistent tie-breaking in both exploration and final selection)
- When UCB1 values are very close, prefer nodes with higher immediate scores
- Improves convergence on direct paths to objectives
### Usage Example
```cpp
// Custom MCTS configuration for performance testing
MCTSConfig config;
config.explorationConstant = 2.0; // More exploration
config.simulationPolicy = MCTSSimulationPolicy::FILTERED_RANDOM; // Faster rollouts
config.numThreads = 8; // Reduce threads for testing environment
config.immediateScoreTieBreakThreshold = 10.0; // More aggressive tie-breaking
MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache, config);
```
### Configuration Recommendations
**For Production Use:**
- Use default settings for balanced performance and quality
- Consider reducing `numThreads` on systems with limited CPU cores
- `BEST_IMMEDIATE` simulation policy provides highest quality decisions
**For Performance Testing:**
- `FILTERED_RANDOM` or `RANDOM` simulation policies for faster rollouts
- Lower `explorationConstant` (1.0) for more exploitation
- Disable transposition detection for baseline comparison
**For Analysis/Debugging:**
- Single-threaded execution (`useMultithreading = false`) for deterministic results
- 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.
+97 -48
View File
@@ -1,5 +1,16 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "ai_common_types",
hdrs = ["AICommonTypes.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/shardok/library:battalion_type",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
],
)
cc_library(
name = "ai_attacker_strategy_selector",
srcs = ["AIAttackerStrategySelector.cpp"],
@@ -28,14 +39,15 @@ cc_library(
hdrs = ["AIAttackGroups.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:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
":ai_attack_locations",
":ai_common_types",
":ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:hex_map_cc_fbs",
"//src/main/flatbuffer/net/eagle0/shardok/storage:unit_cc_fbs",
@@ -47,6 +59,10 @@ cc_library(
srcs = ["AIAttackLocations.cpp"],
hdrs = ["AIAttackLocations.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
":ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library/map:terrain",
@@ -85,11 +101,14 @@ cc_library(
hdrs = ["AIDistanceDebuf.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:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
":ai_attack_locations",
":ai_common_types",
":ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
@@ -118,8 +137,10 @@ cc_library(
hdrs = ["AIScoreUtilities.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:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
@@ -142,22 +163,67 @@ cc_library(
":ai_score_utilities",
":ai_unit_score_calculator",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
cc_library(
name = "ai_heuristic_weighting",
srcs = ["AIHeuristicWeighting.cpp"],
hdrs = ["AIHeuristicWeighting.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
cc_library(
name = "ai_command_evaluator",
srcs = ["AICommandEvaluator.cpp"],
hdrs = ["AICommandEvaluator.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_command_filter",
":ai_strategy",
":transposition_table",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//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/util:hex_cube_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
cc_library(
name = "ai_command_filter",
srcs = ["AICommandFilter.cpp"],
hdrs = ["AICommandFilter.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_common_types",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
@@ -182,38 +248,19 @@ cc_library(
],
)
cc_library(
name = "ai_score_calculator",
srcs = ["AIScoreCalculator.cpp"],
hdrs = ["AIScoreCalculator.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_attacker_strategy_selector",
":ai_command_filter",
":ai_unit_score_calculator",
":ai_victory_condition_score_calculator",
":transposition_table",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:game_state_guesser",
],
)
cc_library(
name = "ai_strategy",
srcs = ["AIStrategy.cpp"],
hdrs = ["AIStrategy.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_attack_groups",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
],
)
@@ -223,6 +270,7 @@ cc_library(
hdrs = ["AIUnitScoreCalculator.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__",
],
@@ -233,27 +281,6 @@ cc_library(
],
)
cc_library(
name = "ai_victory_condition_score_calculator",
srcs = ["AIVictoryConditionScoreCalculator.cpp"],
hdrs = ["AIVictoryConditionScoreCalculator.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
":ai_attack_groups",
":ai_attack_locations",
":ai_distance_debuf",
":ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
],
)
cc_library(
name = "ai_water_crossing_calculator",
srcs = ["AIWaterCrossingCalculator.cpp"],
@@ -261,10 +288,13 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
":ai_common_types",
":ai_minimum_distance_and_target",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
@@ -296,6 +326,7 @@ cc_library(
hdrs = ["AITimeBudget.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
@@ -314,22 +345,34 @@ cc_library(
hdrs = ["IterativeDeepeningAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_attacker_strategy_selector",
":ai_command_evaluator",
":ai_defender_strategy_selector",
":ai_score_calculator",
":ai_time_budget",
":ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/common:time_utils",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
cc_library(
name = "ai_config",
hdrs = ["AIConfig.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
)
cc_library(
name = "shardok_ai_client",
srcs = ["ShardokAIClient.cpp"],
@@ -338,15 +381,21 @@ cc_library(
visibility = ["//visibility:public"],
deps = [
":ai_attacker_strategy_selector",
":ai_config",
":ai_defender_strategy_selector",
":ai_flee_decision_calculator",
":ai_iterative_deepening",
":ai_score_calculator",
":ai_iterative_deepening", # Direct dependency for runtime selection
":ai_time_budget",
":ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/common:time_utils",
"//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",
"//src/main/cpp/net/eagle0/shardok/library/util:game_state_dumper",
"@com_google_protobuf//:protobuf",
],
)
@@ -10,8 +10,9 @@
#include <utility>
#include "AIAttackerStrategySelector.hpp"
#include "AIScoreCalculator.hpp"
#include "AICommandEvaluator.hpp"
#include "TranspositionTable.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
namespace shardok {
@@ -23,14 +24,16 @@ IterativeDeepeningAI::IterativeDeepeningAI(
const bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const AIScoreCalculator& scorer,
const APDCache& apdCache,
const ALCache& alCache)
BattalionTypeGetter battalionTypeGetter)
: playerId(playerId),
isDefender(isDefender),
strategy(std::move(strategy)),
castleCoords(castleCoords),
scorer(scorer),
apdCache(apdCache),
alCache(alCache) {}
battalionTypeGetter(std::move(battalionTypeGetter)) {} // Move the function object
auto IterativeDeepeningAI::IterativeSearch(
const GameSettingsSPtr& settings,
@@ -67,14 +70,8 @@ auto IterativeDeepeningAI::IterativeSearch(
const auto& settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, state);
const auto maxRepeatCount = settingsGetter.Backing().ai_utility_repeat_count();
const ScoreValue currentUtility = AIScoreCalculator::GuessedStateScore(
isDefender,
state,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
const ScoreValue currentUtility =
scorer.GuessedStateScore(isDefender, state, strategy, castleCoords);
// Initialize data structures for tracking scores at each depth
scoresByDepth.clear();
@@ -111,7 +108,7 @@ auto IterativeDeepeningAI::IterativeSearch(
auto future = SearchCommandAtDepthWithEngine(
guessedEngine,
settingsGetter,
scorer,
maxRepeatCount,
commands,
cmdIndex,
@@ -270,7 +267,7 @@ bool IterativeDeepeningAI::IsTimeExpired(const AITimeBudget& budget) {
auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
const ShardokEngine& guessedEngine,
const GameSettings::Getter& settingsGetter,
const AIScoreCalculator& scorer,
const int maxRepeatCount,
const std::vector<CommandProto>& commands,
const size_t commandIndex,
@@ -292,55 +289,47 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
return p.get_future();
}
try {
// Track concurrent evaluations and adjust time accounting
AIEvaluationCounter counter;
const auto startTime = std::chrono::steady_clock::now();
// Track concurrent evaluations and adjust time accounting
AIEvaluationCounter counter;
const auto startTime = std::chrono::steady_clock::now();
// Calculate deadline from remaining time budget
const auto deadline = startTime + timeBudget.remainingBudget;
// Calculate deadline from remaining time budget
const auto deadline = startTime + timeBudget.remainingBudget;
// Get the future from CommandScore - don't wait yet
// Note: CommandScore expects remainingLookahead, not desiredDepth
// desiredDepth 1 = evaluate immediate (remainingLookahead 0)
// desiredDepth 2 = look 1 move ahead (remainingLookahead 1)
// desiredDepth N = look N-1 moves ahead (remainingLookahead N-1)
auto commandScoreFuture = AIScoreCalculator::CommandScore(
playerId,
isDefender,
desiredDepth - 1, // Convert desiredDepth to remainingLookahead
maxRepeatCount,
guessedEngine,
strategy,
currentUtility,
settingsGetter,
castleCoords,
apdCache,
alCache,
commandIndex,
deadline);
// Create command evaluator for lookahead search
AICommandEvaluator evaluator(scorer, apdCache, battalionTypeGetter);
// Calculate time and adjust budget before waiting
// This is needed because we need to update timeBudget synchronously
const auto commandScore = commandScoreFuture.get();
// Get the future from EvaluateCommand - don't wait yet
// Note: EvaluateCommand expects remainingLookahead, not desiredDepth
// desiredDepth 1 = evaluate immediate (remainingLookahead 0)
// desiredDepth 2 = look 1 move ahead (remainingLookahead 1)
// desiredDepth N = look N-1 moves ahead (remainingLookahead N-1)
auto commandScoreFuture = evaluator.EvaluateCommand(
playerId,
isDefender,
desiredDepth - 1, // Convert desiredDepth to remainingLookahead
maxRepeatCount,
guessedEngine,
strategy,
currentUtility,
castleCoords,
commandIndex,
deadline);
const auto elapsed = std::chrono::steady_clock::now() - startTime;
const int concurrentCount = AIEvaluationCounter::GetCurrentCount();
const auto adjustedElapsed = elapsed / std::max(1, concurrentCount);
const auto adjustedElapsedMs =
std::chrono::duration_cast<std::chrono::milliseconds>(adjustedElapsed);
// Calculate time and adjust budget before waiting
// This is needed because we need to update timeBudget synchronously
const auto commandScore = commandScoreFuture.get();
// Deduct adjusted time from remaining budget
timeBudget.remainingBudget -= adjustedElapsedMs;
const auto elapsed = std::chrono::steady_clock::now() - startTime;
const int concurrentCount = AIEvaluationCounter::GetCurrentCount();
const auto adjustedElapsed = elapsed / std::max(1, concurrentCount);
const auto adjustedElapsedMs =
std::chrono::duration_cast<std::chrono::milliseconds>(adjustedElapsed);
result.bestScore = commandScore;
} catch (const std::exception& e) {
// If evaluation fails, return a neutral score rather than crashing
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("SearchCommandAtDepthWithEngine: evaluation failed with exception: %s\n", e.what());
#endif
result.bestScore = 0.0;
}
// Deduct adjusted time from remaining budget
timeBudget.remainingBudget -= adjustedElapsedMs;
result.bestScore = commandScore;
std::promise<SearchResult> p;
p.set_value(result);
@@ -12,6 +12,7 @@
#include "AIStrategy.hpp"
#include "AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -23,6 +24,7 @@ namespace shardok {
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
/// Reason why AI evaluation completed at the achieved depth.
enum class EvaluationCompletionReason {
@@ -61,8 +63,9 @@ public:
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const AIScoreCalculator& scorer,
const APDCache& apdCache,
const ALCache& alCache);
BattalionTypeGetter battalionTypeGetter); // Pass by value
[[nodiscard]] SearchResult IterativeSearch(
const GameSettingsSPtr& settings,
@@ -75,8 +78,9 @@ private:
bool isDefender;
AIStrategy strategy;
CoordsSet castleCoords;
const AIScoreCalculator& scorer;
const APDCache& apdCache;
const ALCache& alCache;
BattalionTypeGetter battalionTypeGetter; // Store by value, not reference!
// Reusable vectors to reduce memory allocations
mutable std::vector<std::vector<ScoreValue>> scoresByDepth;
@@ -87,7 +91,7 @@ private:
[[nodiscard]] std::future<SearchResult> SearchCommandAtDepthWithEngine(
const ShardokEngine& guessedEngine,
const GameSettings::Getter& settingsGetter,
const AIScoreCalculator& scorer,
int maxRepeatCount,
const std::vector<CommandProto>& commands,
size_t commandIndex,
@@ -13,12 +13,16 @@
#include <google/protobuf/util/message_differencer.h>
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp"
#include "AIDefenderStrategySelector.hpp"
#include "AIFleeDecisionCalculator.hpp"
#include "AIScoreUtilities.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.hpp"
#include "src/main/cpp/net/eagle0/common/TimeUtils.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"
#include "src/main/protobuf/net/eagle0/shardok/api/action_result_view.pb.h"
@@ -42,11 +46,17 @@ ShardokAIClient::ShardokAIClient(
const PlayerId playerId,
const bool isDefender,
const HexMap *hexMap,
const SettingsGetter &settings)
const SettingsGetter &settings,
const AIAlgorithmType aiAlgorithmType,
const ScoringCalculatorType scoringCalculatorType,
const mcts::MCTSConfig &mctsConfig)
: playerId(playerId),
isDefender(isDefender),
aiAlgorithmType(aiAlgorithmType),
scoringCalculatorType(scoringCalculatorType),
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
waterCrossingCommandChooser(playerId, apdCache) {
waterCrossingCommandChooser(playerId, apdCache),
mctsConfig(mctsConfig) {
// Pre-generate the most common cache entries for better performance
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
@@ -92,40 +102,108 @@ auto ShardokAIClient::StandardChooseCommandIndex(
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
const auto settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, guessedState);
// Calculate time budget based on game situation using new settings
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState);
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
const auto commandCount = guessedCommands.size();
// Calculate time budget based on game situation using new dynamic per-command settings
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState, commandCount);
// Configure MCTS based on proximity to enemy
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
// - AVERAGING naturally penalizes longer paths through variance
// - No opponent nodes, so no one-bad-child problem
// When close to enemy: use MINIMAX with maxPlayerFlips=1 (adversarial lookahead)
// - MINIMAX correctly models opponent choosing best response
// - Explores through one opponent turn for tactical accuracy
auto adjustedMCTSConfig = mctsConfig;
// adjustedMCTSConfig.maxPlayerFlips = 0;
// if (timeBudget.isCloseToEnemy) {
// adjustedMCTSConfig.maxPlayerFlips = 1;
// adjustedMCTSConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
// if constexpr (kPerformanceLogging) {
// printf("MCTS Config: Close to enemy - using maxPlayerFlips=1, MINIMAX backprop\n");
// }
// } else {
// adjustedMCTSConfig.maxPlayerFlips = 0;
// adjustedMCTSConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::AVERAGING;
// if constexpr (kPerformanceLogging) {
// printf("MCTS Config: Far from enemy - using maxPlayerFlips=0, AVERAGING backprop\n");
// }
// }
assert(commandCount == realAvailableCommands.size());
for (size_t i = 0; i < commandCount; i++) {
CheckCommand(realAvailableCommands[i], guessedCommands[i]);
}
// Extract values directly from settings for strategy selection
const auto maxRounds = settingsGetter.Backing().max_rounds();
const auto braveWaterCost = settingsGetter.Backing().brave_water_action_point_cost();
const auto battalionTypeGetter = [&settingsGetter](BattalionTypeId typeId) {
return settingsGetter.GetBattalionType(typeId);
};
// 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::STANDARD:
default: scorer = MakeStandardAIScoreCalculator(settingsGetter, apdCache, alCache); break;
}
// Determine strategy once for consistent scoring throughout iterative deepening
const auto castleCoords = AllCastleCoords(guessedState->hex_map());
const AIStrategy strategy = isDefender ? AIDefenderStrategySelector::BestDefenderStrategy(
guessedState,
castleCoords,
maxRounds,
apdCache,
settingsGetter)
battalionTypeGetter)
: AIAttackerStrategySelector::BestAttackerStrategy(
playerId,
guessedState,
castleCoords,
maxRounds,
apdCache,
alCache,
settingsGetter,
battalionTypeGetter,
braveWaterCost,
waterCrossingCommandChooser,
realAvailableCommands);
// Use iterative deepening AI for Phase 2 implementation
IterativeDeepeningAI
iterativeAI(playerId, isDefender, strategy, castleCoords, apdCache, alCache);
auto search_result =
iterativeAI.IterativeSearch(settings, guessedState, realAvailableCommands, timeBudget);
// AI implementation chosen at runtime via constructor parameter
IterativeDeepeningAI::SearchResult search_result;
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
// Using Monte Carlo Tree Search AI (with abstraction layer)
ShardokMCTSAI ai(
playerId,
isDefender,
strategy,
castleCoords,
*scorer,
apdCache,
alCache,
adjustedMCTSConfig);
search_result = ai.Search(settings, guessedState, timeBudget);
} else {
// Using Iterative Deepening AI (default)
IterativeDeepeningAI ai(
playerId,
isDefender,
strategy,
castleCoords,
*scorer,
apdCache,
battalionTypeGetter);
search_result =
ai.IterativeSearch(settings, guessedState, realAvailableCommands, timeBudget);
}
CommandChoiceResults result{};
result.chosenIndex = search_result.bestCommandIndex;
@@ -141,9 +219,11 @@ auto ShardokAIClient::StandardChooseCommandIndex(
result.commandCountEvaluated,
result.availableCommandCount);
}
printf("ID AI: Search complete - achieved depth %d for best command %zu\n",
const auto chosenCommandType = realAvailableCommands[result.chosenIndex].type();
printf("ID AI: Search complete - achieved depth %d for best command %zu (%s)\n",
result.depthAchieved,
result.chosenIndex);
result.chosenIndex,
net::eagle0::shardok::common::CommandType_Name(chosenCommandType).c_str());
fflush(stdout);
}
@@ -189,13 +269,21 @@ auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
return LateRoundAttackerChooseCommandIndex(settings, guessedState, realAvailableCommands);
}
// Extract values directly from settings for flee decision evaluation
const auto settingsGetter = settings->GetGetter();
const auto maxRounds = settingsGetter.Backing().max_rounds();
const auto minimumFleeOddsThreshold = settingsGetter.Backing().ai_minimum_flee_odds_threshold();
const auto desperateFleeThreshold = settingsGetter.Backing().ai_desperate_flee_threshold();
// Use the flee decision calculator
const auto fleeDecision = AIFleeDecisionCalculator::EvaluateFleeVsFight(
playerId,
settings->GetGetter(),
guessedState,
realAvailableCommands,
fleeCommand,
maxRounds,
minimumFleeOddsThreshold,
desperateFleeThreshold,
#ifdef DEBUG_FLEE_DECISIONS
true // Enable debug logging
#else
@@ -12,10 +12,12 @@
#include <vector>
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/IterativeDeepeningAI.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
namespace shardok {
@@ -38,12 +40,17 @@ class ShardokAIClient {
private:
const PlayerId playerId;
const bool isDefender;
const AIAlgorithmType aiAlgorithmType;
const ScoringCalculatorType scoringCalculatorType;
APDCache apdCache = std::make_shared<ActionPointDistancesCache>();
ALCache alCache;
const AIWaterCrossingCommandChooser waterCrossingCommandChooser;
// MCTS configuration (only used when aiAlgorithmType == MCTS)
mcts::MCTSConfig mctsConfig;
[[nodiscard]] auto StandardChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
@@ -67,13 +74,20 @@ public:
PlayerId playerId,
bool isDefender,
const HexMap* hexMap,
const SettingsGetter& settings);
const SettingsGetter& settings,
AIAlgorithmType aiAlgorithmType,
ScoringCalculatorType scoringCalculatorType,
const mcts::MCTSConfig& mctsConfig);
~ShardokAIClient() = default;
[[nodiscard]] auto GetPlayerId() const -> PlayerId { return playerId; }
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
};
} // namespace shardok
@@ -0,0 +1,25 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "shardok_mcts_ai",
srcs = ["ShardokMCTSAI.cpp"],
hdrs = ["ShardokMCTSAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/common/mcts/abstract:abstract_mcts_ai",
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening", # For SearchResult compatibility
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
"//src/main/cpp/net/eagle0/shardok/ai:ai_time_budget",
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:shardok_mcts_factory",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -0,0 +1,111 @@
//
// Shardok-specific MCTS AI implementation using abstract interfaces
//
#include "ShardokMCTSAI.hpp"
#include "adapters/ShardokGameEngine.hpp"
#include "adapters/ShardokGameState.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.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/util/HexMapUtils.hpp"
namespace shardok {
ShardokMCTSAI::ShardokMCTSAI(
PlayerId playerId,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const AIScoreCalculator& scoreCalculator,
const APDCache& apdCache,
const ALCache& alCache,
MCTSConfig config)
: abstractAI_(std::make_unique<mcts::AbstractMCTSAI>(
static_cast<mcts::MCTSPlayerId>(
playerId), // Use actual player ID for correct scoring
config)),
isDefender_(isDefender),
strategy_(strategy),
castleCoords_(castleCoords),
scoreCalculator_(scoreCalculator),
apdCache_(apdCache),
alCache_(alCache) {}
auto ShardokMCTSAI::Search(
const GameSettingsSPtr& settings,
const GameStateW& state,
const AITimeBudget& budget) const -> SearchResult {
// Compute critical tiles once to avoid 8.5% runtime overhead in ShardokEngine construction
const auto criticalTiles = GetCriticalTileLocations(state->hex_map());
// Create Shardok engine for simulation
ShardokEngine engine(settings, state, criticalTiles, 0, false);
// Create game state adapter
auto gameState = mcts::ShardokMCTSFactory::createGameState(
state,
&scoreCalculator_, // Pass the score calculator
settings, // Pass shared_ptr directly
isDefender_,
strategy_,
castleCoords_,
apdCache_,
alCache_,
criticalTiles);
// Create game engine adapter (passing critical tiles to avoid recomputation)
auto gameEngine = mcts::ShardokMCTSFactory::createGameEngine(
engine,
&scoreCalculator_, // Pass the score calculator
settings,
apdCache_,
alCache_,
isDefender_,
strategy_,
castleCoords_,
criticalTiles);
// Perform abstract search
const auto timeLimit = budget.remainingBudget;
const auto abstractResult = abstractAI_->Search(*gameEngine, *gameState, timeLimit);
// Report cache statistics for performance analysis
if (auto* shardokEngine = dynamic_cast<mcts::ShardokGameEngine*>(gameEngine.get())) {
shardokEngine->reportCacheStatistics();
}
// Get unfiltered command count for consistent reporting with IterativeDeepeningAI
// (MCTS uses filtered commands internally, but we report unfiltered count for metrics)
const auto unfilteredCommands = engine.GetAvailableCommandsForAIPlayer(
static_cast<PlayerId>(gameState->currentPlayerId()));
const size_t unfilteredCount = unfilteredCommands ? unfilteredCommands->size() : 0;
// Convert result back to Shardok format
SearchResult result;
// Map filtered index back to original unfiltered index
result.bestCommandIndex =
gameEngine->mapFilteredIndexToOriginal(abstractResult.bestActionIndex, *gameState);
result.bestScore = abstractResult.bestScore;
result.depthAchieved = static_cast<size_t>(abstractResult.searchDepth);
result.commandCountEvaluated = static_cast<size_t>(abstractResult.nodesEvaluated);
result.timeUsed = abstractResult.searchTime;
result.availableCommandCount = unfilteredCount;
result.minimumDepthCompleted =
(abstractResult.searchDepth >= static_cast<int>(budget.minDepthRequired));
result.searchCompleted = true; // MCTS is anytime - always returns a valid result
// Determine completion reason based on what actually happened
if (abstractResult.foundWinningMove || unfilteredCount == 0) {
// Found a terminal winning state or no commands available
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
} else {
// Normal case - time budget exhausted while exploring
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_TIME;
}
return result;
}
} // namespace shardok
@@ -0,0 +1,73 @@
//
// Shardok-specific MCTS AI that wraps the abstract implementation
//
#ifndef EAGLE0_SHARDOK_MCTSAI_HPP
#define EAGLE0_SHARDOK_MCTSAI_HPP
#include <memory>
#include <vector>
#include "adapters/ShardokMCTSFactory.hpp"
#include "src/main/cpp/net/eagle0/common/mcts/abstract/AbstractMCTSAI.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/IterativeDeepeningAI.hpp" // For SearchResult compatibility
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#pragma clang diagnostic pop
namespace shardok {
// Forward declarations
class ShardokEngine;
class AICommandFilter;
class AIScoreCalculator;
class ShardokMCTSAI {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using SearchResult = IterativeDeepeningAI::SearchResult;
using MCTSConfig = mcts::MCTSConfig;
ShardokMCTSAI(
PlayerId playerId,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const AIScoreCalculator& scoreCalculator,
const APDCache& apdCache,
const ALCache& alCache,
MCTSConfig config = MCTSConfig{});
// Main search interface - compatible with IterativeDeepeningAI
[[nodiscard]] auto Search(
const GameSettingsSPtr& settings,
const GameStateW& state,
const AITimeBudget& budget) const -> SearchResult;
// Configuration
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return abstractAI_->GetConfig(); }
void SetConfig(const MCTSConfig& newConfig) { abstractAI_->SetConfig(newConfig); }
private:
std::unique_ptr<mcts::AbstractMCTSAI> abstractAI_;
// Shardok-specific context
bool isDefender_;
AIStrategy strategy_;
const CoordsSet& castleCoords_;
const AIScoreCalculator& scoreCalculator_;
const APDCache& apdCache_;
const ALCache& alCache_;
};
} // namespace shardok
#endif // EAGLE0_SHARDOK_MCTSAI_HPP
@@ -0,0 +1,83 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "shardok_action",
srcs = ["ShardokAction.cpp"],
hdrs = ["ShardokAction.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
],
deps = [
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_action",
"//src/main/cpp/net/eagle0/shardok/library:shardok_command",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
cc_library(
name = "shardok_game_state",
srcs = ["ShardokGameState.cpp"],
hdrs = ["ShardokGameState.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
],
deps = [
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_game_state",
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//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/settings:game_settings",
],
)
cc_library(
name = "shardok_game_engine",
srcs = ["ShardokGameEngine.cpp"],
hdrs = ["ShardokGameEngine.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
],
deps = [
":shardok_action",
":shardok_game_state",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_game_engine",
"//src/main/cpp/net/eagle0/shardok/ai:ai_command_filter",
"//src/main/cpp/net/eagle0/shardok/ai:ai_heuristic_weighting",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
],
)
cc_library(
name = "shardok_mcts_factory",
srcs = ["ShardokMCTSFactory.cpp"],
hdrs = ["ShardokMCTSFactory.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
],
deps = [
":shardok_action",
":shardok_game_engine",
":shardok_game_state",
"//src/main/cpp/net/eagle0/shardok/ai:ai_command_filter",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//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/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -0,0 +1,106 @@
//
// Shardok-specific action adapter implementation
//
#include "ShardokAction.hpp"
#include <sstream>
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
#pragma clang diagnostic pop
namespace shardok {
namespace mcts {
// New constructor: store pointer to command (preferred - no proto conversion!)
ShardokAction::ShardokAction(const ShardokCommand* command, size_t index)
: command_(command),
commandIndex_(index) {}
// Legacy constructors for compatibility
ShardokAction::ShardokAction(const CommandProto& command, size_t index)
: command_(nullptr),
commandProto_(command),
commandIndex_(index) {}
ShardokAction::ShardokAction(CommandProto&& command, size_t index)
: command_(nullptr),
commandProto_(std::move(command)),
commandIndex_(index) {}
// Lazy getter - converts to proto only when needed
const ShardokAction::CommandProto& ShardokAction::getCommand() const {
if (command_ != nullptr) {
// Lazily convert Command → Proto (only once)
if (commandProto_.type() == net::eagle0::shardok::common::UNKNOWN_COMMAND) {
commandProto_ = command_->GetCommandProto();
}
return commandProto_;
}
// Already have proto from legacy constructor
return commandProto_;
}
int ShardokAction::getType() const { return static_cast<int>(getCommand().type()); }
std::string ShardokAction::getDescription() const {
const auto& cmd = getCommand();
std::stringstream ss;
// Show player
ss << "P" << static_cast<int>(cmd.player()) << " ";
ss << net::eagle0::shardok::common::CommandType_Name(cmd.type());
if (cmd.has_actor()) { ss << " Unit:" << cmd.actor().value(); }
if (cmd.has_target()) {
const auto& coord = cmd.target();
ss << " @(" << coord.row() << "," << coord.column() << ")";
}
return ss.str();
}
int ShardokAction::getActorId() const {
const auto& cmd = getCommand();
if (cmd.has_actor()) { return cmd.actor().value(); }
return -1;
}
std::pair<int, int> ShardokAction::getTarget() const {
const auto& cmd = getCommand();
if (cmd.has_target()) {
const auto& coord = cmd.target();
return std::make_pair(coord.row(), coord.column());
}
return std::make_pair(-1, -1);
}
std::unique_ptr<MCTSAction> ShardokAction::clone() const {
// Clone by copying command pointer (cheap!) instead of proto
if (command_ != nullptr) { return std::make_unique<ShardokAction>(command_, commandIndex_); }
// Fallback: clone proto
return std::make_unique<ShardokAction>(commandProto_, commandIndex_);
}
bool ShardokAction::equals(const MCTSAction& other) const {
const auto* shardokOther = dynamic_cast<const ShardokAction*>(&other);
if (!shardokOther) { return false; }
// Fast path: compare by command pointer
if (command_ != nullptr && shardokOther->command_ != nullptr) {
return commandIndex_ == shardokOther->commandIndex_ && command_ == shardokOther->command_;
}
// Fallback: compare protos
return commandIndex_ == shardokOther->commandIndex_ &&
getCommand().SerializeAsString() == shardokOther->getCommand().SerializeAsString();
}
} // namespace mcts
} // namespace shardok
@@ -0,0 +1,59 @@
//
// Shardok-specific action adapter for MCTS
//
#ifndef EAGLE0_SHARDOK_ACTION_HPP
#define EAGLE0_SHARDOK_ACTION_HPP
#include <memory>
#include <string>
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSAction.hpp"
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#pragma clang diagnostic pop
namespace shardok {
// Forward declaration
class ShardokCommand;
namespace mcts {
class ShardokAction : public MCTSAction {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
// New constructor: store pointer to Command instead of copying proto
ShardokAction(const ShardokCommand* command, size_t index);
// Legacy constructors for compatibility (creates internal proto copy)
ShardokAction(const CommandProto& command, size_t index);
ShardokAction(CommandProto&& command, size_t index);
// MCTSAction interface implementation
[[nodiscard]] size_t getIndex() const override { return commandIndex_; }
[[nodiscard]] std::string getDescription() const override;
[[nodiscard]] std::unique_ptr<MCTSAction> clone() const override;
[[nodiscard]] bool equals(const MCTSAction& other) const override;
// Shardok-specific methods (not part of abstract interface)
[[nodiscard]] int getType() const;
[[nodiscard]] int getActorId() const;
[[nodiscard]] std::pair<int, int> getTarget() const;
// Shardok-specific accessor - lazily converts to proto if needed
[[nodiscard]] const CommandProto& getCommand() const;
private:
// Store pointer to command (preferred) OR proto copy (legacy)
const ShardokCommand* command_ = nullptr; // Non-owning pointer to cached command
mutable CommandProto commandProto_; // Lazy proto copy (only used if command_ is null)
size_t commandIndex_;
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_SHARDOK_ACTION_HPP
@@ -0,0 +1,526 @@
//
// Shardok-specific game engine adapter implementation
//
#include "ShardokGameEngine.hpp"
#include <chrono>
#include "ShardokAction.hpp"
#include "ShardokGameState.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.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/settings/GameSettings.hpp"
namespace shardok::mcts {
// Deterministic random generator for predictable combat rolls (50th percentile)
// This matches the behavior used in IterativeDeepening AI
static const std::vector<double> kAverageSequence = {0.5};
static const auto kAverageGenerator = std::make_shared<SequenceRandomGenerator>(kAverageSequence);
// Thread-local cache for legal actions (avoids lock contention in multithreaded simulation)
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache>
ShardokGameEngine::legalActionsCache_;
thread_local uint64_t ShardokGameEngine::cacheHits_ = 0;
thread_local uint64_t ShardokGameEngine::cacheMisses_ = 0;
thread_local uint64_t ShardokGameEngine::transitionCacheHits_ = 0;
thread_local uint64_t ShardokGameEngine::transitionCacheMisses_ = 0;
thread_local uint64_t ShardokGameEngine::timeInHashComputation_ = 0;
thread_local uint64_t ShardokGameEngine::timeInLegalActionsComputation_ = 0;
ShardokGameEngine::ShardokGameEngine(
[[maybe_unused]] const ShardokEngine* engine,
const AIScoreCalculator* scoreCalculator,
const GameSettingsSPtr& gameSettings,
const APDCache* apdCache,
const ALCache* alCache,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const CoordsSet& criticalTileCoords)
: scoreCalculator_(scoreCalculator),
gameSettings_(gameSettings),
apdCache_(apdCache),
alCache_(alCache),
isDefender_(isDefender),
strategy_(strategy),
castleCoords_(castleCoords),
criticalTileCoords_(criticalTileCoords) {
// Thread-local cache is automatically initialized per thread
// Reserve space to reduce rehashing (based on profiling: ~30-50K unique states per search)
legalActionsCache_.reserve(100000);
}
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
const MCTSGameState& state,
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) { return nullptr; }
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for deterministic transitions
// Check transition cache: have we applied this (action, roll) to this state before?
TransitionKey transitionKey{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto transIt = it->second.actionResults.find(transitionKey);
transIt != it->second.actionResults.end()) {
// We've applied this transition before - get the next state hash
uint64_t nextStateHash = transIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash);
nextIt != legalActionsCache_.end()) {
// TRANSITION CACHE HIT: We have the complete next state cached!
transitionCacheHits_++;
// Clone the cached engine and return the state
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
return std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
gameSettings_.get(),
isDefender_,
strategy_,
castleCoords_,
*apdCache_,
*alCache_,
criticalTileCoords_);
}
}
}
// TRANSITION CACHE MISS: Apply action normally
transitionCacheMisses_++;
// Use cached engine if available (avoids recomputing GetAvailableCommands for same state)
std::shared_ptr<ShardokEngine> engine;
if (auto cachedEngine = shardokState->getCachedEngine()) {
// Clone the cached engine to preserve command cache
engine = std::make_shared<ShardokEngine>(*cachedEngine);
} else {
// Create fresh engine and populate command cache
engine = std::make_shared<ShardokEngine>(
gameSettings_,
shardokState->getShardokState(),
criticalTileCoords_,
0,
false);
// Populate command cache (result intentionally unused, just populating cache)
[[maybe_unused]] const auto commands =
engine->GetAvailableCommandsForAIPlayer(currentPlayer);
// Cache the engine for future use with this state
shardokState->setCachedEngine(engine);
// Clone it for applying the action (don't mutate the cached engine)
engine = std::make_shared<ShardokEngine>(*engine);
}
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
// Create and return the new state (don't cache the mutated engine)
auto newState = std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
gameSettings_.get(),
isDefender_,
strategy_,
castleCoords_,
*apdCache_,
*alCache_,
criticalTileCoords_);
// Store the transition in cache for future lookups
const uint64_t nextStateHash = newState->hash();
legalActionsCache_[currentStateHash].actionResults[transitionKey] = nextStateHash;
// CRITICAL: Also store the next state's engine in the cache
// Without this, transition cache lookups will always miss because the next state won't exist
// Clone the engine so we have an independent cached copy
legalActionsCache_[nextStateHash].engine = std::make_shared<ShardokEngine>(*engine);
// Don't pre-compute hash - let it be computed lazily on first use
// Many states (especially in simulation) never need their hash computed
return newState;
}
void ShardokGameEngine::applyActionMutable(
std::unique_ptr<MCTSGameState>& state,
const MCTSAction& action) const {
auto* shardokState = dynamic_cast<ShardokGameState*>(state.get());
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) {
// Fallback to default implementation
state = applyAction(*state, action);
return;
}
const auto currentPlayer = static_cast<PlayerId>(state->currentPlayerId());
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for deterministic transitions
// Check transition cache: have we applied this (action, roll) to this state before?
TransitionKey transitionKey{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto transIt = it->second.actionResults.find(transitionKey);
transIt != it->second.actionResults.end()) {
// We've applied this transition before - get the next state hash
uint64_t nextStateHash = transIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash);
nextIt != legalActionsCache_.end()) {
// TRANSITION CACHE HIT: We have the complete next state cached!
transitionCacheHits_++;
// Clone the cached engine and update the current state to match
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
shardokState->setCachedEngine(engine);
shardokState->invalidateHashCache();
return;
}
}
}
// TRANSITION CACHE MISS: Apply action normally
transitionCacheMisses_++;
// Use cached engine if available
std::shared_ptr<ShardokEngine> engine;
if (auto cachedEngine = shardokState->getCachedEngine()) {
engine = std::make_shared<ShardokEngine>(*cachedEngine);
} else {
engine = std::make_shared<ShardokEngine>(
gameSettings_,
shardokState->getShardokState(),
criticalTileCoords_,
0,
false);
// Populate command cache (result intentionally unused, just populating cache)
[[maybe_unused]] const auto commands =
engine->GetAvailableCommandsForAIPlayer(currentPlayer);
shardokState->setCachedEngine(engine);
engine = std::make_shared<ShardokEngine>(*engine);
}
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
// Clear the cached engine and hash since the state has been mutated
shardokState->setCachedEngine(nullptr);
shardokState->invalidateHashCache();
// Store the transition in cache for future lookups
const uint64_t nextStateHash = shardokState->hash();
legalActionsCache_[currentStateHash].actionResults[transitionKey] = nextStateHash;
// CRITICAL: Also store the next state's engine in the cache
// Without this, transition cache lookups will always miss because the next state won't exist
// Clone the engine so we have an independent cached copy
legalActionsCache_[nextStateHash].engine = std::make_shared<ShardokEngine>(*engine);
}
std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
const MCTSGameState& state,
MCTSPlayerId /*rootPlayerId*/,
int currentPlayerFlips,
int maxPlayerFlips) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
if (!shardokState) { return {}; }
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
// Check if we've exceeded the maximum allowed player flips
// currentPlayerFlips is the number of times the player has changed since root
// maxPlayerFlips is the maximum number of changes we allow
// If maxPlayerFlips is 0, only explore root player's moves (stop when player first changes)
// If maxPlayerFlips is 1, explore through opponent's response (stop after opponent's moves)
if (currentPlayerFlips > maxPlayerFlips) {
return {}; // Stop exploration - we've exceeded the flip limit
}
// Time hash computation
const auto hashStart = std::chrono::high_resolution_clock::now();
const uint64_t stateHash = shardokState->hash();
const auto hashEnd = std::chrono::high_resolution_clock::now();
timeInHashComputation_ +=
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count();
// Check transposition table for cached legal actions
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
cacheHits_++;
// Use cached engine
shardokState->setCachedEngine(it->second.engine);
// If we have cached actions, clone and return them (fastest path)
if (!it->second.cachedActions.empty()) {
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(it->second.cachedActions.size());
for (const auto& action : it->second.cachedActions) {
actions.push_back(action->clone());
}
return actions;
}
// Fallback: reconstruct actions from cached engine (shouldn't happen often)
const CommandListSPtr commands =
it->second.engine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (!commands || commands->empty()) { return {}; }
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(it->second.filteredIndices.size());
for (const size_t origIdx : it->second.filteredIndices) {
if (origIdx < commands->size()) {
const auto& cmd = commands->at(origIdx);
// Use new constructor: pass command pointer instead of converting to proto
actions.push_back(std::make_unique<ShardokAction>(cmd.get(), origIdx));
}
}
return actions;
}
cacheMisses_++;
// Time legal actions computation
const auto actionsStart = std::chrono::high_resolution_clock::now();
// Use cached engine if available, otherwise create and cache it
std::shared_ptr<ShardokEngine> engine;
if (auto cachedEngine = shardokState->getCachedEngine()) {
engine = cachedEngine;
} else {
engine = std::make_shared<ShardokEngine>(
gameSettings_,
shardokState->getShardokState(),
criticalTileCoords_);
shardokState->setCachedEngine(engine);
}
const CommandListSPtr commands = engine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (!commands || commands->empty()) { return {}; }
// Filter commands using AICommandFilter (matching original MCTSAI behavior)
// Use gameSettings for battalion type lookups
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
commands,
currentPlayer,
isDefender_,
shardokState->getShardokState(),
*apdCache_,
[this](BattalionTypeId typeId) {
return gameSettings_->GetGetter().GetBattalionType(typeId);
});
// Convert only filtered commands to MCTSActions
// Use new constructor: pass command pointer instead of converting to proto (major speedup!)
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(filteredIndices.size());
for (const size_t idx : filteredIndices) {
if (idx < commands->size()) {
const auto& cmd = commands->at(idx);
actions.push_back(std::make_unique<ShardokAction>(cmd.get(), idx));
}
}
const auto actionsEnd = std::chrono::high_resolution_clock::now();
timeInLegalActionsComputation_ +=
std::chrono::duration_cast<std::chrono::microseconds>(actionsEnd - actionsStart)
.count();
// Store in transposition table for future lookups
LegalActionsCache entry;
entry.filteredIndices = filteredIndices;
entry.engine = engine;
// Clone actions to cache them (allows fast retrieval without reconstruction)
entry.cachedActions.reserve(actions.size());
for (const auto& action : actions) { entry.cachedActions.push_back(action->clone()); }
legalActionsCache_[stateHash] = std::move(entry);
return actions;
}
bool ShardokGameEngine::isTerminal(const MCTSGameState& state) const { return state.isTerminal(); }
double ShardokGameEngine::evaluateState(const MCTSGameState& state, MCTSPlayerId playerId) const {
return state.score(playerId);
}
std::vector<size_t> ShardokGameEngine::filterActions(
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const MCTSGameState& /*state*/) const {
// All filtering is already done in getLegalActions() using AICommandFilter
// This method is used by simulation policies and doesn't need additional filtering
std::vector<size_t> indices;
indices.reserve(actions.size());
for (size_t i = 0; i < actions.size(); ++i) { indices.push_back(i); }
return indices;
}
std::vector<double> ShardokGameEngine::getActionWeights(
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const MCTSGameState& state) const {
// Cast to ShardokGameState to access Shardok-specific methods
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
if (!shardokState) {
throw MCTSInternalError(
"ShardokGameEngine::getActionWeights called with non-Shardok state - this "
"indicates a type mismatch in the MCTS adapter layer");
}
// Check cache for action weights
const uint64_t stateHash = shardokState->hash();
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
if (!it->second.actionWeights.empty() &&
it->second.actionWeights.size() == actions.size()) {
// Cache hit! Return cached weights
return it->second.actionWeights;
}
}
// Cache miss: compute action weights using AIHeuristicWeighting
std::vector<double> weights;
weights.reserve(actions.size());
for (const auto& action : actions) {
const auto* shardokAction = dynamic_cast<const ShardokAction*>(action.get());
if (!shardokAction) {
throw MCTSInternalError(
"ShardokGameEngine::getActionWeights encountered non-Shardok action - this "
"indicates a type mismatch in the MCTS adapter layer");
}
weights.push_back(AIHeuristicWeighting::GetCommandWeight(
shardokAction->getCommand(),
shardokState->getShardokState(),
castleCoords_,
apdCache_,
isDefender_,
[this](BattalionTypeId typeId) {
return gameSettings_->GetGetter().GetBattalionType(typeId);
}));
}
// Store weights in cache for future lookups
legalActionsCache_[stateHash].actionWeights = weights;
return weights;
}
double ShardokGameEngine::getActionScore(
const MCTSGameState& state,
const MCTSAction& action,
MCTSPlayerId playerId) const {
auto newState = applyAction(state, action);
if (!newState) { return 0.0; }
return newState->score(playerId);
}
bool ShardokGameEngine::shouldStopSearch(
const MCTSGameState& /*state*/,
int /*iterations*/,
std::chrono::steady_clock::time_point /*startTime*/) const {
// Could add early termination logic here
return false;
}
size_t ShardokGameEngine::mapFilteredIndexToOriginal(
size_t filteredIndex,
const MCTSGameState& state) const {
// Get the filtered actions (uses cached engine)
auto actions = getLegalActions(state, state.currentPlayerId(), 0, 0);
// Check bounds
if (filteredIndex >= actions.size()) { return filteredIndex; }
// Extract the original index from the ShardokAction
const auto* shardokAction = dynamic_cast<const ShardokAction*>(actions[filteredIndex].get());
if (!shardokAction) { return filteredIndex; }
// ShardokAction stores the original unfiltered index
return shardokAction->getIndex();
}
void ShardokGameEngine::reportCacheStatistics() const {
const uint64_t totalLookups = cacheHits_ + cacheMisses_;
if (totalLookups > 0) {
const double hitRate = static_cast<double>(cacheHits_) / static_cast<double>(totalLookups);
const double avgHashTimeUs =
static_cast<double>(timeInHashComputation_) / static_cast<double>(totalLookups);
const double avgActionsTimeUs =
cacheMisses_ > 0 ? static_cast<double>(timeInLegalActionsComputation_) /
static_cast<double>(cacheMisses_)
: 0.0;
printf("Legal Actions Cache Stats:\n");
printf(" Lookups: %llu hits, %llu misses, %.1f%% hit rate, %zu entries\n",
static_cast<unsigned long long>(cacheHits_),
static_cast<unsigned long long>(cacheMisses_),
hitRate * 100.0,
legalActionsCache_.size());
printf(" Timing: %.2f us avg hash, %.2f us avg actions (on miss)\n",
avgHashTimeUs,
avgActionsTimeUs);
printf(" Total time: %.2f ms in hash, %.2f ms in actions\n",
timeInHashComputation_ / 1000.0,
timeInLegalActionsComputation_ / 1000.0);
// Calculate if transposition table is worth it
const double timeWithCache = timeInHashComputation_ + timeInLegalActionsComputation_;
const double timeWithoutCache =
avgActionsTimeUs * static_cast<double>(totalLookups); // All lookups recompute
const double savings = (timeWithoutCache - timeWithCache) / timeWithoutCache * 100.0;
printf(" Cache savings: %.1f%% vs. no cache (%.2f ms saved)\n",
savings,
(timeWithoutCache - timeWithCache) / 1000.0);
}
// Report transition cache statistics
const uint64_t totalTransitions = transitionCacheHits_ + transitionCacheMisses_;
if (totalTransitions > 0) {
const double transitionHitRate =
static_cast<double>(transitionCacheHits_) / static_cast<double>(totalTransitions);
printf("\nTransition Cache Stats:\n");
printf(" Transitions: %llu hits, %llu misses, %.1f%% hit rate\n",
static_cast<unsigned long long>(transitionCacheHits_),
static_cast<unsigned long long>(transitionCacheMisses_),
transitionHitRate * 100.0);
// Calculate total number of transition entries across all states
size_t totalTransitionEntries = 0;
for (const auto& [stateHash, cache] : legalActionsCache_) {
totalTransitionEntries += cache.actionResults.size();
}
printf(" Total transition entries: %zu (avg %.1f per state)\n",
totalTransitionEntries,
totalTransitionEntries > 0 ? static_cast<double>(totalTransitionEntries) /
static_cast<double>(legalActionsCache_.size())
: 0.0);
}
}
void ShardokGameEngine::resetCacheStatistics() {
cacheHits_ = 0;
cacheMisses_ = 0;
timeInHashComputation_ = 0;
timeInLegalActionsComputation_ = 0;
transitionCacheHits_ = 0;
transitionCacheMisses_ = 0;
}
} // namespace shardok::mcts
@@ -0,0 +1,154 @@
//
// Shardok-specific game engine adapter for MCTS
//
#ifndef EAGLE0_SHARDOK_GAME_ENGINE_HPP
#define EAGLE0_SHARDOK_GAME_ENGINE_HPP
#include <atomic>
#include <functional>
#include <gtl/phmap.hpp>
#include <memory>
#include <vector>
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSGameEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#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/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
// Forward declarations
class AICommandFilter;
class AIScoreCalculator;
class RandomGenerator;
// Use existing type definitions from the Shardok codebase
// GameSettingsSPtr and SettingsGetter are defined in GameSettings.hpp
namespace mcts {
class ShardokGameEngine : public MCTSGameEngine {
public:
ShardokGameEngine(
const ShardokEngine* engine,
const AIScoreCalculator* scoreCalculator,
const GameSettingsSPtr& gameSettings,
const APDCache* apdCache,
const ALCache* alCache,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const CoordsSet& criticalTileCoords);
// MCTSGameEngine interface implementation
[[nodiscard]] std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action) const override;
void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
const override;
[[nodiscard]] std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
const MCTSGameState& state,
MCTSPlayerId rootPlayerId,
int currentPlayerFlips,
int maxPlayerFlips) const override;
[[nodiscard]] bool isTerminal(const MCTSGameState& state) const override;
[[nodiscard]] double evaluateState(const MCTSGameState& state, MCTSPlayerId playerId)
const override;
[[nodiscard]] std::vector<size_t> filterActions(
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const MCTSGameState& state) const override;
[[nodiscard]] std::vector<double> getActionWeights(
const std::vector<std::unique_ptr<MCTSAction>>& actions,
const MCTSGameState& state) const override;
[[nodiscard]] double getActionScore(
const MCTSGameState& state,
const MCTSAction& action,
MCTSPlayerId playerId) const override;
[[nodiscard]] bool shouldStopSearch(
const MCTSGameState& state,
int iterations,
std::chrono::steady_clock::time_point startTime) const override;
[[nodiscard]] size_t mapFilteredIndexToOriginal(
size_t filteredIndex,
const MCTSGameState& state) const override;
// Report transposition table statistics
void reportCacheStatistics() const;
// Reset cache statistics
void resetCacheStatistics();
private:
// Key for transition cache: (actionIndex, roll)
// Caches state transitions to avoid redundant PostCommand calls
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
// Transposition table entry for caching legal actions
struct LegalActionsCache {
std::vector<size_t> filteredIndices;
std::shared_ptr<ShardokEngine> engine; // Engine with populated command cache
// NEW: Cache state transitions (action, roll) -> nextStateHash
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;
// NEW: Cache action weights to avoid recomputing heuristics
std::vector<double> actionWeights;
// NEW: Cache the actual action objects to avoid repeated allocation/construction
std::vector<std::unique_ptr<MCTSAction>> cachedActions;
};
const AIScoreCalculator* scoreCalculator_;
const GameSettingsSPtr gameSettings_;
const APDCache* apdCache_;
const ALCache* alCache_;
bool isDefender_;
AIStrategy strategy_;
const CoordsSet& castleCoords_;
// Computed once to avoid 8.5% overhead per engine construction
const CoordsSet& criticalTileCoords_;
// Transposition table for legal actions (thread-local to avoid lock contention)
// Each thread maintains its own cache during multithreaded simulation
static thread_local gtl::flat_hash_map<uint64_t, LegalActionsCache> legalActionsCache_;
static thread_local uint64_t cacheHits_;
static thread_local uint64_t cacheMisses_;
// Transition cache statistics
static thread_local uint64_t transitionCacheHits_;
static thread_local uint64_t transitionCacheMisses_;
// Performance timing (in microseconds)
static thread_local uint64_t timeInHashComputation_;
static thread_local uint64_t timeInLegalActionsComputation_;
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_SHARDOK_GAME_ENGINE_HPP
@@ -0,0 +1,103 @@
//
// Shardok-specific game state adapter implementation
//
#include "ShardokGameState.hpp"
#include <sstream>
#include <string>
#include <utility>
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok::mcts {
ShardokGameState::ShardokGameState(
GameStateW state,
const AIScoreCalculator* calculator,
const GameSettings* settings,
const bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
const CoordsSet& criticalTileCoords)
: state_(std::move(state)),
scoreCalculator_(calculator),
settings_(settings),
isDefender_(isDefender),
strategy_(std::move(strategy)),
castleCoords_(castleCoords),
apdCache_(apdCache),
alCache_(alCache),
criticalTileCoords_(criticalTileCoords) {}
uint64_t ShardokGameState::hash() const {
if (!hashCached_) {
cachedHash_ = state_.ComputeFNV1aHash();
hashCached_ = true;
}
return cachedHash_;
}
double ShardokGameState::score(MCTSPlayerId /*playerId*/) const {
// Always return score from the perspective of the AI that created this state (isDefender_).
// The playerId parameter is ignored - adversarial logic happens in selection, not scoring.
return scoreCalculator_->GuessedStateScore(isDefender_, state_, strategy_, castleCoords_);
}
MCTSPlayerId ShardokGameState::currentPlayerId() const { return state_->current_player(); }
bool ShardokGameState::isTerminal() const {
// Check if game status indicates the game is over
if (state_->status()) {
const auto gameStatus = state_->status()->state();
if (gameStatus == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY ||
gameStatus == net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
return true;
}
}
// Check max rounds
if (state_->current_round() >= settings_->GetGetter().Backing().max_rounds()) { return true; }
return false;
}
std::unique_ptr<MCTSGameState> ShardokGameState::clone() const {
auto cloned = std::make_unique<ShardokGameState>(
state_,
scoreCalculator_,
settings_,
isDefender_,
strategy_,
castleCoords_,
apdCache_,
alCache_,
criticalTileCoords_);
// Don't copy the cached engine - each state needs its own
return cloned;
}
bool ShardokGameState::equals(const MCTSGameState& other) const {
const auto* shardokOther = dynamic_cast<const ShardokGameState*>(&other);
if (!shardokOther) { return false; }
return hash() == shardokOther->hash();
}
MCTSPlayerId ShardokGameState::getWinner() const {
// Note: FlatBuffer doesn't have a winner field
// In practice, this would need to determine winner from victory conditions
return -1; // No winner
}
std::string ShardokGameState::toString() const {
std::stringstream ss;
ss << "ShardokGameState[Round:" << static_cast<int>(state_->current_round())
<< " Player:" << currentPlayerId() << " Hash:" << hash() << "]";
return ss.str();
}
} // namespace shardok::mcts
@@ -0,0 +1,83 @@
//
// Shardok-specific game state adapter for MCTS
//
#ifndef EAGLE0_SHARDOK_GAME_STATE_HPP
#define EAGLE0_SHARDOK_GAME_STATE_HPP
#include <memory>
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSGameState.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.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/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
// Forward declarations
class AIScoreCalculator;
namespace mcts {
class ShardokGameState : public MCTSGameState {
public:
ShardokGameState(
GameStateW state,
const AIScoreCalculator* calculator,
const GameSettings* settings,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
const CoordsSet& criticalTileCoords);
// MCTSGameState interface implementation
[[nodiscard]] uint64_t hash() const override;
[[nodiscard]] double score(MCTSPlayerId playerId) const override;
[[nodiscard]] MCTSPlayerId currentPlayerId() const override;
[[nodiscard]] bool isTerminal() const override;
[[nodiscard]] std::unique_ptr<MCTSGameState> clone() const override;
[[nodiscard]] bool equals(const MCTSGameState& other) const override;
[[nodiscard]] MCTSPlayerId getWinner() const override;
[[nodiscard]] std::string toString() const override;
// Shardok-specific accessors
[[nodiscard]] const GameStateW& getShardokState() const { return state_; }
[[nodiscard]] GameStateW& getMutableShardokState() { return state_; }
[[nodiscard]] bool isDefender() const { return isDefender_; }
[[nodiscard]] const GameSettings* getSettings() const { return settings_; }
[[nodiscard]] const CoordsSet& getCriticalTileCoords() const { return criticalTileCoords_; }
// Engine caching for performance (avoids recomputing available commands)
void setCachedEngine(std::shared_ptr<ShardokEngine> engine) const { cachedEngine_ = engine; }
[[nodiscard]] std::shared_ptr<ShardokEngine> getCachedEngine() const { return cachedEngine_; }
// Invalidate hash cache when state is mutated
void invalidateHashCache() const {
hashCached_ = false;
cachedHash_ = 0;
}
private:
GameStateW state_;
const AIScoreCalculator* scoreCalculator_;
const GameSettings* settings_;
bool isDefender_;
AIStrategy strategy_;
const CoordsSet& castleCoords_;
const APDCache& apdCache_;
const ALCache& alCache_;
mutable uint64_t cachedHash_ = 0;
mutable bool hashCached_ = false;
const CoordsSet& criticalTileCoords_;
mutable std::shared_ptr<ShardokEngine> cachedEngine_; // Engine with cached available commands
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_SHARDOK_GAME_STATE_HPP
@@ -0,0 +1,85 @@
//
// Factory implementation for creating Shardok-specific MCTS components
//
#include "ShardokMCTSFactory.hpp"
#include "ShardokAction.hpp"
#include "ShardokGameEngine.hpp"
#include "ShardokGameState.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok::mcts {
std::unique_ptr<MCTSGameEngine> ShardokMCTSFactory::createGameEngine(
const ShardokEngine& engine,
const AIScoreCalculator* scoreCalculator,
const GameSettingsSPtr& gameSettings,
const APDCache& apdCache,
const ALCache& alCache,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const CoordsSet& criticalTileCoords) {
return std::make_unique<ShardokGameEngine>(
&engine,
scoreCalculator,
gameSettings,
&apdCache,
&alCache,
isDefender,
strategy,
castleCoords,
criticalTileCoords);
}
std::unique_ptr<MCTSGameState> ShardokMCTSFactory::createGameState(
const GameStateW& state,
const AIScoreCalculator* scoreCalculator,
const GameSettingsSPtr& settings,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
const CoordsSet& criticalTileCoords) {
return std::make_unique<ShardokGameState>(
state,
scoreCalculator,
settings.get(), // Get raw pointer from shared_ptr
isDefender,
strategy,
castleCoords,
apdCache,
alCache,
criticalTileCoords);
}
std::vector<std::unique_ptr<MCTSAction>> ShardokMCTSFactory::createActions(
const std::vector<CommandProto>& commands) {
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(commands.size());
for (size_t i = 0; i < commands.size(); ++i) {
actions.push_back(std::make_unique<ShardokAction>(commands[i], i));
}
return actions;
}
std::vector<std::unique_ptr<MCTSAction>> ShardokMCTSFactory::createActionsFromCommandList(
const CommandListSPtr& commands) {
std::vector<std::unique_ptr<MCTSAction>> actions;
if (!commands) { return actions; }
actions.reserve(commands->size());
for (size_t i = 0; i < commands->size(); ++i) {
const auto& cmd = (*commands)[i];
actions.push_back(std::make_unique<ShardokAction>(cmd->GetCommandProto(), i));
}
return actions;
}
} // namespace shardok::mcts
@@ -0,0 +1,85 @@
//
// Factory for creating Shardok-specific MCTS components
//
#ifndef EAGLE0_SHARDOK_MCTS_FACTORY_HPP
#define EAGLE0_SHARDOK_MCTS_FACTORY_HPP
#include <functional>
#include <memory>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
class AICommandFilter;
class AIScoreCalculator;
class GameStateW;
class GameSettings;
// Use existing type definitions to avoid conflicts
// These are already defined in the Shardok codebase:
// - APDCache in ActionPointDistancesCache.hpp
// - ALCache in AIAttackLocations.hpp
// - CommandListSPtr in ShardokCommand.hpp
// - SettingsGetter in GameSettings.hpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
namespace mcts {
// Forward declarations
class MCTSGameEngine;
class MCTSGameState;
class MCTSAction;
class ShardokMCTSFactory {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
// Create a Shardok game engine adapter
[[nodiscard]] static std::unique_ptr<MCTSGameEngine> createGameEngine(
const ShardokEngine& engine,
const AIScoreCalculator* scoreCalculator,
const GameSettingsSPtr& gameSettings,
const APDCache& apdCache,
const ALCache& alCache,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const CoordsSet& criticalTileCoords);
// Create a Shardok game state adapter
[[nodiscard]] static std::unique_ptr<MCTSGameState> createGameState(
const GameStateW& state,
const AIScoreCalculator* scoreCalculator,
const GameSettingsSPtr& settings,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
const CoordsSet& criticalTileCoords);
// Convert Shardok commands to MCTS actions
[[nodiscard]] static std::vector<std::unique_ptr<MCTSAction>> createActions(
const std::vector<CommandProto>& commands);
// Convert from command list to MCTS actions
[[nodiscard]] static std::vector<std::unique_ptr<MCTSAction>> createActionsFromCommandList(
const CommandListSPtr& commands);
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_SHARDOK_MCTS_FACTORY_HPP
@@ -0,0 +1,58 @@
//
// Created by dancrosby on 3/4/20.
//
#ifndef EAGLE0_AISCORECALCULATOR_HPP
#define EAGLE0_AISCORECALCULATOR_HPP
#include <future>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
using shardok::PlayerId;
using std::future;
using std::vector;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
// Forward declarations
class ShardokEngine;
struct AIStrategy;
/// Abstract base class for AI scoring algorithms.
/// Allows testing different scoring strategies by implementing different scorers.
class AIScoreCalculator {
public:
virtual ~AIScoreCalculator() = default;
// Rule of five: explicitly default or delete copy/move operations
AIScoreCalculator(const AIScoreCalculator &) = default;
AIScoreCalculator &operator=(const AIScoreCalculator &) = default;
AIScoreCalculator(AIScoreCalculator &&) = default;
AIScoreCalculator &operator=(AIScoreCalculator &&) = default;
protected:
AIScoreCalculator() = default;
public:
/// Evaluate the score of a guessed game state based on the current AI strategy.
/// DOES NOT perform lookahead - this is pure state evaluation.
/// For lookahead search, use AICommandEvaluator which depends on this interface.
[[nodiscard]] virtual auto GuessedStateScore(
bool isDefender,
const GameStateW &state,
const AIStrategy &aiStrategy,
const CoordsSet &allCastleCoords) const -> ScoreValue = 0;
};
} // namespace shardok
#endif // EAGLE0_AISCORECALCULATOR_HPP
@@ -7,9 +7,9 @@
#include <algorithm>
#include <ranges>
#include "AIAttackLocations.hpp"
#include "AIDistanceDebuf.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/AIDistanceDebuf.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/victory_condition.hpp"
@@ -43,8 +43,8 @@ auto AttackerDebufForOnFireCriticalTile(
const vector<const Unit*>& extinguishingUnits,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings,
const int braveWaterActionPointCost,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const bool lateGame) -> double {
double minDebuf = 99999.9;
@@ -60,8 +60,8 @@ auto AttackerDebufForOnFireCriticalTile(
extinguishingUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
lateGame,
/* includeUndead = */ false);
if (newDebuf < minDebuf) minDebuf = newDebuf;
@@ -77,8 +77,8 @@ auto AttackerDebufForUnoccupiedCriticalTile(
const vector<const Unit*>& claimableUnits,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings,
const int braveWaterActionPointCost,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const bool lateGame) -> double {
return UNHELD_VALUE * DefenderDistanceBuf(
criticalTileLocation,
@@ -87,8 +87,8 @@ auto AttackerDebufForUnoccupiedCriticalTile(
claimableUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
lateGame,
/* includeUndead = */ false);
}
@@ -100,8 +100,8 @@ auto AttackerDebufForDefenderOccupiedCriticalTile(
const vector<const Unit*>& attackerUnits,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings,
const int braveWaterActionPointCost,
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const bool lateGame) {
const double baseUnitValue =
defenderUnit->battalion().size() +
@@ -117,8 +117,8 @@ auto AttackerDebufForDefenderOccupiedCriticalTile(
attackerUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
lateGame,
/* includeUndead = */ false);
}
@@ -126,10 +126,7 @@ auto AttackerDebufForDefenderOccupiedCriticalTile(
auto DefenderHoldsCriticalTilesVictoryScore(
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const PlayerInfo* player,
const APDCache& /*apdCache*/,
const ALCache& /*alCache*/,
const SettingsGetter& /*settings*/) -> ScoreValue {
const PlayerInfo* player) -> ScoreValue {
ScoreValue total = 0.0;
const auto rc = gameState->hex_map()->row_count();
@@ -159,7 +156,8 @@ auto AttackerHoldsCriticalTilesVictoryScore(
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings) -> ScoreValue {
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> ScoreValue {
vector<const Unit*> playerUnits{};
vector<const Unit*> claimablePlayerUnits{};
for (const Unit* unit : *gameState->units()) {
@@ -175,7 +173,6 @@ auto AttackerHoldsCriticalTilesVictoryScore(
return criticalTileLocations.size() * MAX_DEFENDER_HELD_VALUE;
}
const int braveWaterActionPointCost = settings.Backing().brave_water_action_point_cost();
const MapId mapId = apdCache->GetMapId(gameState->hex_map());
ScoreValue total = 0.0;
@@ -202,8 +199,8 @@ auto AttackerHoldsCriticalTilesVictoryScore(
claimablePlayerUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
IsLateGame(gameState));
total += BADLY_HELD_VALUE;
}
@@ -215,8 +212,8 @@ auto AttackerHoldsCriticalTilesVictoryScore(
playerUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
IsLateGame(gameState));
}
} else if (terrain->modifier().fire().present()) {
@@ -227,8 +224,8 @@ auto AttackerHoldsCriticalTilesVictoryScore(
claimablePlayerUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
IsLateGame(gameState));
} else {
total -= AttackerDebufForUnoccupiedCriticalTile(
@@ -238,8 +235,8 @@ auto AttackerHoldsCriticalTilesVictoryScore(
claimablePlayerUnits,
apdCache,
alCache,
settings,
braveWaterActionPointCost,
battalionTypeGetter,
braveWaterCost,
IsLateGame(gameState));
}
}
@@ -252,7 +249,8 @@ auto LastPlayerStandingVictoryScore(
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings) -> ScoreValue {
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> ScoreValue {
if (!std::ranges::contains(
*player->victory_conditions(),
net::eagle0::shardok::storage::fb::
@@ -285,8 +283,8 @@ auto LastPlayerStandingVictoryScore(
playerUnits,
apdCache,
alCache,
settings,
5,
battalionTypeGetter,
braveWaterCost,
IsLateGame(gameState),
/* includeUndead = */ true);
}
@@ -9,6 +9,7 @@
#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/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
@@ -29,22 +30,21 @@ auto AttackerHoldsCriticalTilesVictoryScore(
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings) -> ScoreValue;
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> ScoreValue;
auto DefenderHoldsCriticalTilesVictoryScore(
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings) -> ScoreValue;
const PlayerInfo* player) -> ScoreValue;
auto LastPlayerStandingVictoryScore(
const GameStateW& gameState,
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
const SettingsGetter& settings) -> ScoreValue;
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost) -> ScoreValue;
} // namespace shardok
@@ -0,0 +1,113 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "ai_score_calculator_interface",
hdrs = ["AIScoreCalculator.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 = [
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_locations",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
cc_library(
name = "ai_victory_condition_score_calculator",
srcs = ["AIVictoryConditionScoreCalculator.cpp"],
hdrs = ["AIVictoryConditionScoreCalculator.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/score/private:__pkg__", # Needed by abstract base class
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_locations",
"//src/main/cpp/net/eagle0/shardok/ai:ai_common_types",
"//src/main/cpp/net/eagle0/shardok/ai:ai_distance_debuf",
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
],
)
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"],
hdrs = ["StandardAIScoreCalculator.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/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
],
)
cc_library(
name = "mcts_optimized_ai_score_calculator",
srcs = ["MCTSOptimizedAIScoreCalculator.cpp"],
hdrs = ["MCTSOptimizedAIScoreCalculator.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",
],
)
@@ -0,0 +1,243 @@
//
// MCTS-Optimized implementation of AIScoreCalculator
// Uses bounded linear scoring tuned for MCTS exploration/exploitation balance
//
#include "MCTSOptimizedAIScoreCalculator.hpp"
#include <algorithm>
#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::FleeStrategyScoreForState;
using score_calculator_internal::UnitsScoreComponents;
// Scoring constants tuned for MCTS
namespace {
// Scale constants designed to produce score differences in the range that works well for MCTS
// With C=1.41 and typical parent visits ~10000, exploration term ≈ 0.42
// We want:
// - Early game tactical moves: 0.3-0.5 difference (ratio 0.7-1.2x exploration)
// - Mid game advantages (10-30%): 4.0-8.0 difference (ratio 9-19x exploration)
// - Late game crushing advantages: 10-40 difference (ratio 24-95x exploration)
// Maximum contribution from proportional unit advantage (applies to both battle sizes)
// A 100% unit advantage (all attacker, no defender) produces ±80 score
constexpr double UNITS_SCORE_SCALE = 80.0;
// Normalizer for victory condition scores (also proportional to army size)
// Typical victory condition range: -3000 to 0 (raw), becomes approximately -30 to 0 (normalized)
constexpr double VICTORY_SCORE_SCALE = 400.0;
// Minimum reference value to avoid division by zero in edge cases
constexpr double MIN_REFERENCE_VALUE = 1000.0;
} // anonymous namespace
/// MCTS-Optimized implementation of AIScoreCalculator.
/// Produces bounded linear scores that balance MCTS exploration and exploitation.
/// Inherits from AbstractAIScoreCalculator to share common functionality.
class MCTSOptimizedAIScoreCalculator : public AbstractAIScoreCalculator {
public:
MCTSOptimizedAIScoreCalculator(
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;
};
// Implementation of MCTSOptimizedAIScoreCalculator methods
auto MCTSOptimizedAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const
-> ScoreValue {
// Use bounded values instead of INT_MAX/MIN for numerical stability
switch (outcome) {
case GameOutcome::DEFENDER_VICTORY: return 1000.0;
case GameOutcome::ATTACKER_VICTORY: return -1000.0;
case GameOutcome::DRAW: return 0.0;
case GameOutcome::FLEE_OUTCOME: return 0.0;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
}
auto MCTSOptimizedAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const
-> ScoreValue {
// Use bounded values instead of INT_MAX/MIN for numerical stability
switch (outcome) {
case GameOutcome::ATTACKER_VICTORY: return 1000.0;
case GameOutcome::DEFENDER_VICTORY: return -1000.0;
case GameOutcome::DRAW: return 0.0;
case GameOutcome::FLEE_OUTCOME: return 0.0;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
}
auto MCTSOptimizedAIScoreCalculator::CombineAttackerScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int roundsRemaining) const -> ScoreValue {
// Use actual total army value as reference (scales with battle size)
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
// Normalize proportional unit difference to approximately [-80, +80] range
const double unitsDiff = components.attackerUnitsValue - components.defenderUnitsValue;
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
// Normalize victory condition (also proportional to army size) to approximately [-40, 0] range
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
// Weight units by rounds remaining (early: units matter less, late: units dominate)
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return unitsMultiplier * unitsScore + victoryScore;
}
auto MCTSOptimizedAIScoreCalculator::CombineDefenderScatterScores(
const UnitsScoreComponents &components) const -> ScoreValue {
// Use actual total army value as reference
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
// For scatter strategy, just maximize proportional defender advantage
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
return (unitsDiff / reference) * UNITS_SCORE_SCALE;
}
auto MCTSOptimizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int roundsRemaining) const -> ScoreValue {
// Use actual total army value as reference
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
// Similar to attacker, but from defender's perspective
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return unitsMultiplier * unitsScore + victoryScore;
}
auto MCTSOptimizedAIScoreCalculator::DefenderFleeStrategyScoreForState(
const GameStateW &gameState) const -> ScoreValue {
for (const auto *pi : *gameState->player_infos()) {
if (pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
}
throw ShardokInternalErrorException("Unable to find defender for FleeStrategy");
}
auto MCTSOptimizedAIScoreCalculator::AttackerFleeStrategyScoreForState(
const GameStateW &gameState) const -> ScoreValue {
for (const PlayerInfo *pi : *gameState->player_infos()) {
if (!pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
}
throw ShardokInternalErrorException("Unable to find attacker for FleeStrategy");
}
auto MCTSOptimizedAIScoreCalculator::GuessedStateScore(
const bool isDefender,
const GameStateW &state,
const AIStrategy &aiStrategy,
const CoordsSet &allCastleCoords) const -> ScoreValue {
const int roundsRemaining = GetMaxRounds() - state->current_round();
if (isDefender) {
return DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
}
return AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
}
// Factory function implementation
auto MakeMCTSOptimizedAIScoreCalculator(
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<MCTSOptimizedAIScoreCalculator>(
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
@@ -0,0 +1,32 @@
//
// MCTS-Optimized implementation of AIScoreCalculator
// Uses bounded linear scoring tuned for MCTS exploration/exploitation balance
//
#ifndef EAGLE0_MCTSOPTIMIZEDAISCORECALCULATOR_HPP
#define EAGLE0_MCTSOPTIMIZEDAISCORECALCULATOR_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 an MCTSOptimizedAIScoreCalculator.
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
[[nodiscard]] auto MakeMCTSOptimizedAIScoreCalculator(
const SettingsGetter& settingsGetter,
const APDCache& apdCache,
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
} // namespace shardok
#endif // EAGLE0_MCTSOPTIMIZEDAISCORECALCULATOR_HPP
@@ -0,0 +1,388 @@
# MCTS-Optimized Scoring Algorithm Design
## Problem Statement
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).
### MCTS Requirements
After ~100 visits, the **exploitation term** (cumulative_score / visits) should be comparable to the **exploration term** (C * sqrt(ln(parent_visits) / visits)).
With C=1.41 and typical parent visits ~10000:
- Exploration term: 1.41 * sqrt(ln(10000) / 100) ≈ 0.42
- **Target exploitation differences: 0.5 to 2.0**
This means individual scores should differ by **0.5 to 2.0** between meaningfully different positions.
## Scale Analysis from Codebase
### Unit Values
- Single unit context-free value: 500-3000 (depends on battalion type, stats, size)
- With modifiers (castle, terrain, ranged): 1000-6000 per unit
- Full army (10 units max): 10,000-40,000
- Typical strong army: ~20,000
### Victory Condition Scores
- Castle held by defender: -(battalion_size + vigor) * distance_debuf ≈ -800 per castle
- Distance debuf: 0.0 (adjacent) to 1.0 (unreachable), typically 0.9-0.95
- 3 castles held by defender at medium distance: ≈ -2400
- 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)
- Captured unit: -10,000
- Captured VIP: -25,000
## Proposed Algorithm: Bounded Linear Scorer
### Design Principles
1. **Normalized scale**: Map scores to approximately [-15, +15] range
2. **Separate components**: Units and victory conditions contribute separately
3. **Preserve relative importance**: Victory conditions dominate early, units become important as advantage grows
4. **Round-based weighting**: Similar to Standard scorer, weight units by rounds remaining
### Constants
```cpp
constexpr double UNITS_SCORE_SCALE = 80.0; // Max contribution from proportional unit advantage
constexpr double VICTORY_SCORE_SCALE = 400.0; // Normalizer for victory conditions (also proportional)
constexpr double MIN_REFERENCE_VALUE = 1000.0; // Avoid division by zero in edge cases
```
**Key insights**:
1. Both unit scores AND victory condition scores scale proportionally with battle size (victory scores use battalion.size() in their calculation). Therefore, we normalize both by the **actual total army value** rather than a fixed reference.
2. **MCTS requires stronger signal than minimax**: Minimax (Iterative Deepening) just picks argmax, so even tiny score differences (0.01) work fine. MCTS needs score differences comparable to the exploration term (~0.4-0.5) to guide search effectively. We use 10x larger scale constants to amplify tactical differences like positioning, distance to objectives, and incremental unit advantages.
### Attacker Score Formula
```cpp
auto CombineAttackerScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
int roundsRemaining) const -> ScoreValue {
// Use actual total army value as reference (scales with battle size)
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
// Normalize proportional unit difference to [-8, +8] range
const double unitsDiff = components.attackerUnitsValue - components.defenderUnitsValue;
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
// Normalize victory condition (also proportional to army size) to approximately [-10, 0] range
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
// Weight units by rounds remaining (early: units matter less, late: units dominate)
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return unitsMultiplier * unitsScore + victoryScore;
}
```
### Defender Score Formula
```cpp
auto CombineDefenderScatterScores(
const UnitsScoreComponents &components) const -> ScoreValue {
// Use actual total army value as reference
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
// For scatter strategy, just maximize proportional defender advantage
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
return (unitsDiff / reference) * UNITS_SCORE_SCALE;
}
auto CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
int roundsRemaining) const -> ScoreValue {
// Use actual total army value as reference
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
// Similar to attacker, but from defender's perspective
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return unitsMultiplier * unitsScore + victoryScore;
}
```
### Terminal States
```cpp
auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue {
switch (outcome) {
case GameOutcome::ATTACKER_VICTORY: return 1000.0; // Large but bounded
case GameOutcome::DEFENDER_VICTORY: return -1000.0;
case GameOutcome::DRAW: return 0.0;
case GameOutcome::FLEE_OUTCOME: return 0.0;
}
}
```
Note: Using bounded values (±1000) instead of INT_MAX/MIN ensures numerical stability in MCTS and clearer signal that these are terminal states.
## Example Score Traces
### Large Battle Scenarios (10v10, ~40000 total army)
#### Scenario 1: Even armies, attacker needs to capture 3 castles
- Units: attacker 20000, defender 20000 (total: 40000)
- Victory: -2850 (3 castles * 950 each, medium distance)
- Rounds: 15/30 remaining
Score:
- reference = 40000
- unitsDiff = 0
- unitsScore = 0
- victoryScore = (-2850 / 40000) * 400.0 = -28.5
- unitsMultiplier = 0.5
- **total = 0.5 * 0 + (-28.5) = -28.5**
#### Scenario 2: Slight attacker advantage (10%)
- Units: attacker 22000, defender 18000 (diff: +4000, total: 40000)
- Victory: -2850
- Rounds: 15/30
Score:
- unitsScore = (4000 / 40000) * 80.0 = 8.0
- victoryScore = -28.5
- unitsMultiplier = 0.5
- **total = 0.5 * 8.0 + (-28.5) = -24.5**
- **Difference from Scenario 1: 4.0**
#### Scenario 3: Large attacker advantage (30%)
- Units: attacker 26000, defender 14000 (diff: +12000, total: 40000)
- Victory: -2850
- Rounds: 15/30
Score:
- unitsScore = (12000 / 40000) * 80.0 = 24.0
- victoryScore = -28.5
- unitsMultiplier = 0.5
- **total = 0.5 * 24.0 + (-28.5) = -16.5**
- **Difference from Scenario 2: 8.0**
### Small Battle Scenarios (2v2, ~4000 total army)
#### Scenario 4: Even small armies, 1 castle
- Units: attacker 2000, defender 2000 (total: 4000)
- Victory: -475 (1 castle * 500 * 0.95 distance)
- Rounds: 15/30
Score:
- reference = 4000
- unitsScore = 0
- victoryScore = (-475 / 4000) * 400.0 = -47.5
- **total = 0.5 * 0 + (-47.5) = -47.5**
#### Scenario 5: Slight advantage in small battle (10%)
- Units: attacker 2200, defender 1800 (diff: +400, total: 4000)
- Victory: -475
- Rounds: 15/30
Score:
- unitsScore = (400 / 4000) * 80.0 = 8.0
- victoryScore = -47.5
- **total = 0.5 * 8.0 + (-47.5) = -43.5**
- **Difference from Scenario 4: 4.0**
### Early Game Scenario: Single unit movement
#### Scenario 6: Early game, single unit advances toward castle
- Units: attacker 20000, defender 20000 (total: 40000)
- Victory before: -2850 (distance debuf = 0.95)
- Victory after: -2829 (distance debuf = 0.943, one unit moved closer)
- Change in victory score: +21
- Rounds: 28/30 (early game)
Score change:
- victoryScoreChange = (21 / 40000) * 400.0 = 0.21
- Additionally, the moving unit (value 2000) gets better distance multiplier:
- Before: 2000 * 0.25 = 500
- After: 2000 * 0.279 = 558
- Diff = 58, normalized: (58 / 40000) * 80.0 = 0.116
- unitsMultiplier = 28/30 = 0.933
- **Total improvement: 0.21 + 0.933 * 0.116 = 0.32**
With exploration term ~0.42, this gives exploitation/exploration ratio of **0.76** - still below 1.0 but much better than before (was 0.05). MCTS will slightly prefer better moves while still exploring alternatives.
### Scale Consistency Verification
Comparing **10% advantage** in both battle sizes:
- Large battle (Scenario 2): diff = **4.0**
- Small battle (Scenario 5): diff = **4.0**
**Perfect scaling!** Same proportional advantage → same score difference, regardless of battle size.
Early game tactical moves now produce meaningful signals (0.3-0.5 range) that guide MCTS while still allowing healthy exploration.
## MCTS Behavior Verification
After 100 visits with C=1.41, exploration term ~0.42:
**Early game (single unit tactical moves):**
- Good positioning move: **0.32** (ratio 0.76x exploration)
- MCTS explores broadly but slightly favors better moves
**Mid game (unit advantages matter):**
- 10% army advantage: **4.0** (ratio 9.5x exploration)
- 30% army advantage: **8.0** (ratio 19x exploration)
- MCTS strongly commits to maintaining/increasing army advantage
**Late game (large differences):**
- Major strategic advantages: **10-40** (ratio 24-95x exploration)
- MCTS decisively exploits winning positions
This progression is ideal:
- **Early game**: Healthy exploration (ratio < 1.0) when moves are genuinely similar
- **Mid game**: Strong exploitation (ratio 9-19x) when clear advantages exist
- **Late game**: Decisive exploitation (ratio > 20x) to close out wins
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)
## Why MCTS Needs Stronger Signal Than Minimax
**Iterative Deepening (minimax)** works fine with tiny score differences (0.01-0.1) because:
- It explores all moves to the same depth
- It simply picks `argmax(scores)`
- Even a 0.01 difference causes it to prefer the better move
**MCTS** needs much larger differences (0.3-4.0) because:
- It uses UCB formula: `score/visits + C*sqrt(ln(parent_visits)/visits)`
- The exploration term (~0.4) can dominate small exploitation differences
- With differences < 0.1, MCTS explores all moves almost equally (over-exploration)
- With differences > 10.0, MCTS commits too early (over-exploitation)
**Solution**: Use 10x larger scale constants than initially designed, specifically tuned so that:
- Early game tactical moves (positioning, distance) produce 0.3-0.5 differences
- Mid game advantages (10-30% army strength) produce 4.0-8.0 differences
- Late game crushing advantages produce 10-40 differences
This gives MCTS the right balance: explore when moves are similar, exploit when advantages are clear.
## Implementation Notes
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard and Normalized
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
6. **Scale constants tuned for MCTS**: 10x larger than naive normalization to provide appropriate signal strength
## Testing with Integration Tests
Before integrating with MCTS, test the new scorer with **IterativeDeepeningAI** using the AI integration test infrastructure.
### Integration Test Infrastructure
The codebase now has comprehensive AI integration tests in `src/test/cpp/net/eagle0/shardok/ai/AIIntegrationTest.cpp` that use:
1. **AIPerformanceTestHelpers** (`src/test/cpp/net/eagle0/shardok/library/AIPerformanceTestHelpers.{cpp,hpp}`):
- `CreatePerfTestGameState(settings, defenderToggle)` creates a 6v6 scenario on the Alah map
- Properly initializes units with correct battalion sizes (800 for longbowmen, capacity-based for others)
- Handles both attacker and defender perspectives
- Returns GameStateW in SETUP phase with 6 units per player in reserve
2. **ShardokAIClient** integration:
- Tests use the full AI client interface, not just the search algorithm
- Time budgets set to 3s for reasonable test execution time
- Handles both setup phase placement and first turn movement
3. **Acceptable Position Sets** for handling AI non-determinism:
- AI decisions may vary due to internal tie-breaking and search order
- Tests define sets of acceptable positions for each unit
- Example from AttackerAI_Setup_PlacesUnitsCorrectly:
```cpp
std::set<net::eagle0::shardok::storage::fb::Coords> acceptablePositions{
net::eagle0::shardok::storage::fb::Coords(0, 11),
net::eagle0::shardok::storage::fb::Coords(1, 10),
// ... more acceptable positions
};
```
### Adding Tests for New Scorers
To test MCTSOptimizedAIScoreCalculator (or any new scorer) with IterativeDeepeningAI:
1. **Add test cases following the existing pattern** in `AIIntegrationTest.cpp`:
```cpp
TEST(MCTSOptimizedScorerTest, AttackerAI_Setup_PlacesUnitsCorrectly) {
auto settings = GetDefaultGameSettingsForTest();
auto gameStateW = CreatePerfTestGameState(settings, /*defenderToggle=*/false);
auto hexMap = gameStateW.GetHexMap().ToProto();
// Use MCTSOptimizedAIScoreCalculator instead of StandardAIScoreCalculator
auto scoreCalculator = std::make_shared<MCTSOptimizedAIScoreCalculator>(
/*playerId=*/0, /*isDefender=*/false, hexMap, settings->GetGetter());
ShardokAIClient client(
/*playerId=*/0, /*isDefender=*/false, hexMap, settings,
scoreCalculator, std::chrono::milliseconds(3000));
// ... rest of test follows existing pattern
}
```
2. **Update BUILD.bazel** to add the new scorer as a dependency:
```bazel
deps = [
# ... existing deps ...
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator",
]
```
3. **Test patterns to implement**:
- **Setup Phase Tests**: Verify AI places units in reasonable starting positions
- `AttackerAI_Setup_PlacesUnitsCorrectly`: Attacker should place at start zone (0,11)-(1,13)
- `DefenderAI_Setup_OccupiesCastles`: Defender should occupy castle tiles
- **First Turn Tests**: Verify AI makes sensible initial moves
- `AttackerAI_FirstTurn_MovesUnitsCorrectly`: Attacker should advance toward objectives
- Use acceptable position sets to handle non-determinism
- **Score Range Verification**: Add assertions to verify scores are in expected ranges
```cpp
// Example: verify scores are bounded as expected
auto searchResult = client.GetBestCommand(gameStateW);
EXPECT_GE(searchResult.score, -50.0); // Reasonable lower bound
EXPECT_LE(searchResult.score, 50.0); // Reasonable upper bound
```
4. **Performance Regression Testing**:
- Run `./scripts/ai_perf_test.sh` to verify the new scorer doesn't cause performance degradation
- Compare commands evaluated at each depth vs. StandardAIScoreCalculator
- See CLAUDE.md "Performance Testing" section for detailed instructions
### Why Test with IterativeDeepeningAI First
The new scoring algorithm should work with **both** IterativeDeepeningAI and MCTS:
- If it fails with IterativeDeepeningAI, the scoring logic itself is broken
- If it passes with IterativeDeepeningAI but fails with MCTS, the issue is MCTS-specific
- This allows incremental testing and debugging
Once the scorer passes integration tests with IterativeDeepeningAI, then integrate with MCTS and compare behavior.
## Alternative Names
- `BoundedLinearAIScoreCalculator`
- `MCTSOptimizedAIScoreCalculator`
- `LinearNormalizedAIScoreCalculator`
Recommend: **`MCTSOptimizedAIScoreCalculator`** to clearly indicate purpose.
@@ -0,0 +1,252 @@
//
// 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
@@ -0,0 +1,44 @@
//
// 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
@@ -0,0 +1,281 @@
//
// Standard implementation of AIScoreCalculator
//
#include "StandardAIScoreCalculator.hpp"
#include <atomic>
#include <chrono>
#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/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::AttackerMultiplierForTargetDistance;
using score_calculator_internal::CAPTURED_UNIT_SCORE;
using score_calculator_internal::CAPTURED_VIP_SCORE;
using score_calculator_internal::EffectiveDistanceCache;
using score_calculator_internal::FleeStrategyScoreForState;
using score_calculator_internal::UNITS_BASE_MULTIPLIER;
// Forward declare the implementation class
class StandardAIScoreCalculator;
// Anonymous namespace for helper functions that don't need access to scorer
namespace {
#define LOGGING_ 0
#define PERFORMANCE_LOGGING_ 0
// Performance logging for AttackerScoreForState
struct AttackerScorePerformanceLogger {
static constexpr int LOG_INTERVAL = 100000;
static std::atomic<int> callCount;
static std::atomic<double> intervalTime;
static std::atomic<double> totalTime;
static void LogCall(double duration) {
callCount.fetch_add(1);
intervalTime.fetch_add(duration);
totalTime.fetch_add(duration);
if (callCount.load() % LOG_INTERVAL == 0) {
double intervalAvg = intervalTime.load() / LOG_INTERVAL;
double overallAvg = totalTime.load() / callCount.load();
printf("AttackerScoreForState: %d calls, last %d avg: %.1f µs, overall avg: %.1f µs\n",
callCount.load(),
LOG_INTERVAL,
intervalAvg * 1000000.0,
overallAvg * 1000000.0);
intervalTime.store(0.0); // Reset for next interval
}
}
};
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
// RAII timer for automatic performance logging
class AttackerScoreTimer {
private:
std::chrono::high_resolution_clock::time_point startTime;
public:
AttackerScoreTimer() : startTime(std::chrono::high_resolution_clock::now()) {}
~AttackerScoreTimer() {
auto endTime = std::chrono::high_resolution_clock::now();
auto duration =
std::chrono::duration_cast<std::chrono::duration<double>>(endTime - startTime);
AttackerScorePerformanceLogger::LogCall(duration.count());
}
};
} // anonymous namespace
/// Standard implementation of AIScoreCalculator that uses the default scoring algorithm.
/// Inherits from AbstractAIScoreCalculator to share common functionality.
class StandardAIScoreCalculator : public AbstractAIScoreCalculator {
public:
StandardAIScoreCalculator(
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:
// Implementation methods (converted from internal namespace functions)
[[nodiscard]] auto AttackerUnitsScore(
const GameStateW &gameState,
int roundsRemaining,
bool attackerWantsCastles,
bool defenderShouldScatter,
const vector<TargetPriorityList> &attackerTargetPriorities,
const MapId &mapId) const -> ScoreValue;
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue override;
};
// Implementation of StandardAIScoreCalculator methods
auto StandardAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue {
switch (outcome) {
case GameOutcome::DEFENDER_VICTORY: return INT_MAX;
case GameOutcome::ATTACKER_VICTORY: return INT_MIN;
case GameOutcome::DRAW: return 0;
case GameOutcome::FLEE_OUTCOME: return 0;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
}
auto StandardAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue {
switch (outcome) {
case GameOutcome::ATTACKER_VICTORY: return INT_MAX;
case GameOutcome::DEFENDER_VICTORY: return INT_MIN;
case GameOutcome::DRAW: return 0;
case GameOutcome::FLEE_OUTCOME: return 0;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
}
auto StandardAIScoreCalculator::AttackerUnitsScore(
const GameStateW &gameState,
int roundsRemaining,
bool attackerWantsCastles,
bool defenderShouldScatter,
const vector<TargetPriorityList> &attackerTargetPriorities,
const MapId &mapId) const -> ScoreValue {
// Use the base class implementation to get separated attacker/defender values
const auto components = CalculateUnitsScoreComponents(
gameState,
roundsRemaining,
attackerWantsCastles,
defenderShouldScatter,
attackerTargetPriorities,
mapId);
// Standard scorer returns the difference (attacker - defender)
return components.attackerUnitsValue - components.defenderUnitsValue;
}
auto StandardAIScoreCalculator::CombineDefenderScatterScores(
const UnitsScoreComponents &components) const -> ScoreValue {
// For defender scatter, we want to maximize defender units and minimize attacker units
// From defender's perspective: negate the attacker-defender difference
return components.defenderUnitsValue - components.attackerUnitsValue;
}
auto StandardAIScoreCalculator::CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int roundsRemaining) const -> ScoreValue {
// From defender's perspective: negate the attacker-defender difference
const double unitsDifference = components.defenderUnitsValue - components.attackerUnitsValue;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
}
auto StandardAIScoreCalculator::DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue {
for (const auto *pi : *gameState->player_infos()) {
if (pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
}
throw ShardokInternalErrorException("Unable to find defender for FleeStrategy");
}
auto StandardAIScoreCalculator::AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue {
for (const PlayerInfo *pi : *gameState->player_infos()) {
if (!pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
}
throw ShardokInternalErrorException("Unable to find attacker for FleeStrategy");
}
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;
}
auto StandardAIScoreCalculator::GuessedStateScore(
const bool isDefender,
const GameStateW &state,
const AIStrategy &aiStrategy,
const CoordsSet &allCastleCoords) const -> ScoreValue {
const int roundsRemaining = GetMaxRounds() - state->current_round();
if (isDefender) {
return DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
}
return AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
}
// Factory function implementation
auto MakeStandardAIScoreCalculator(
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<StandardAIScoreCalculator>(
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
@@ -0,0 +1,31 @@
//
// Standard implementation of AIScoreCalculator
//
#ifndef EAGLE0_STANDARDAISCORECALCULATOR_HPP
#define EAGLE0_STANDARDAISCORECALCULATOR_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 StandardAIScoreCalculator.
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
[[nodiscard]] auto MakeStandardAIScoreCalculator(
const SettingsGetter& settingsGetter,
const APDCache& apdCache,
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
} // namespace shardok
#endif // EAGLE0_STANDARDAISCORECALCULATOR_HPP
@@ -0,0 +1,129 @@
//
// Shared utilities for AI score calculators - implementation
//
#include "AIScoreCalculatorSharedUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
namespace shardok {
namespace score_calculator_internal {
auto EffectiveDistanceCache::GetOrCompute(
const Unit* unit,
const Coords& target,
const ActionPointDistances* notBravingApd,
const ActionPointDistances* bravingApd,
const HexMap* hexMap) const -> DIST_T {
CacheKey key{unit->unit_id(), target};
auto it = cache.find(key);
if (it != cache.end()) { return it->second; }
CoordsSet targetSet(hexMap);
targetSet.Add(target);
DIST_T result = EffectiveDistance(unit, notBravingApd, bravingApd, targetSet);
cache[key] = result;
return result;
}
auto FleeStrategyScoreForState(const GameStateW& gameState, const PlayerId playerId) -> ScoreValue {
ScoreValue scoreValue = 0.0;
const auto* gameStatePtr = gameState.Get();
const auto* units = gameStatePtr->units();
for (const auto* unit : *units) {
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
if (unit->player_id() == playerId &&
unit->battalion().type() != net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD) {
scoreValue += FLEE_UNIT_SCORE;
if (unit->has_attached_hero() &&
unit->attached_hero().control_info().controlled_unit_id() != -1) {
scoreValue += FLEE_CONTROLLING_UNIT_SCORE;
}
}
}
return scoreValue;
}
// Forward declaration for recursive helper
static auto RecursiveAttackerMultiplierForTargetDistance(
const Unit* attackingUnit,
std::vector<TargetAndAttackLocations>::const_iterator& priorityListNext,
const std::vector<TargetAndAttackLocations>::const_iterator& priorityListEnd,
const std::vector<const Unit*>& occupants,
const HexMap* map,
const BattalionTypeSPtr& battType,
const ActionPointDistances* notBravingApd,
const ActionPointDistances* bravingApd,
bool isLateGame) -> double;
static auto RecursiveAttackerMultiplierForTargetDistance(
const Unit* attackingUnit,
std::vector<TargetAndAttackLocations>::const_iterator& priorityListNext,
const std::vector<TargetAndAttackLocations>::const_iterator& priorityListEnd,
const std::vector<const Unit*>& occupants,
const HexMap* map,
const BattalionTypeSPtr& battType,
const ActionPointDistances* notBravingApd,
const ActionPointDistances* bravingApd,
const bool isLateGame) -> double {
if (priorityListNext == priorityListEnd) return 1.0;
const auto& [target, attackLocations] = *priorityListNext;
const Coords& topPriorityTarget = target;
// If the target is unoccupied or is occupied by this player, give the maximum multiplier, but
// also add the bonus for the next up in the priority list
if (const Unit* occupant = occupants
[topPriorityTarget.row() * map->column_count() + topPriorityTarget.column()];
!occupant || occupant->player_id() == attackingUnit->player_id()) {
return kMaxProximityBuf + RecursiveAttackerMultiplierForTargetDistance(
attackingUnit,
++priorityListNext,
priorityListEnd,
occupants,
map,
battType,
notBravingApd,
bravingApd,
isLateGame);
}
// Use optimized EffectiveDistance with pre-computed ActionPointDistances
// attackLocations is already the CoordsSet of attack locations for this target
const DIST_T distance =
EffectiveDistance(attackingUnit, notBravingApd, bravingApd, attackLocations);
return kMaxProximityBuf / (1 + distance / kDistanceDebufRatio);
}
auto AttackerMultiplierForTargetDistance(
const Unit* attackingUnit,
const std::vector<TargetAndAttackLocations>& priorityList,
const std::vector<const Unit*>& occupants,
const HexMap* map,
const BattalionTypeSPtr& battType,
const ActionPointDistances* notBravingApd,
const ActionPointDistances* bravingApd,
const bool isLateGame) -> double {
auto iter = std::begin(priorityList);
return RecursiveAttackerMultiplierForTargetDistance(
attackingUnit,
iter,
std::end(priorityList),
occupants,
map,
battType,
notBravingApd,
bravingApd,
isLateGame);
}
} // namespace score_calculator_internal
} // namespace shardok
@@ -0,0 +1,95 @@
//
// Shared utilities for AI score calculators
// This file is private to the ai/score package
//
#ifndef EAGLE0_AI_SCORE_CALCULATOR_SHARED_UTILITIES_HPP
#define EAGLE0_AI_SCORE_CALCULATOR_SHARED_UTILITIES_HPP
#include <vector>
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wthread-safety-analysis"
#pragma GCC diagnostic ignored "-Wunused-result"
#include <gtl/phmap.hpp>
#pragma GCC diagnostic pop
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state_generated.h"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/hex_map_generated.h"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit_generated.h"
namespace shardok {
namespace score_calculator_internal {
using HexMap = net::eagle0::shardok::storage::fb::HexMap;
using Unit = net::eagle0::shardok::storage::fb::Unit;
// Scoring constants shared across all calculators
constexpr double UNITS_BASE_MULTIPLIER = 0.05;
constexpr double FLEE_UNIT_SCORE = -10000;
constexpr double FLEE_CONTROLLING_UNIT_SCORE = -10000;
constexpr double CAPTURED_UNIT_SCORE = -10000;
constexpr double CAPTURED_VIP_SCORE = -25000;
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
struct UnitsScoreComponents {
double attackerUnitsValue;
double defenderUnitsValue;
};
/// Memoization cache for EffectiveDistance calls
struct EffectiveDistanceCache {
struct CacheKey {
UnitId unitId;
Coords target;
bool operator==(const CacheKey& other) const {
return unitId == other.unitId && target == other.target;
}
};
struct CacheKeyHash {
size_t operator()(const CacheKey& key) const {
return std::hash<UnitId>{}(key.unitId) ^ (std::hash<int>{}(key.target.row()) << 1) ^
(std::hash<int>{}(key.target.column()) << 2);
}
};
mutable gtl::flat_hash_map<CacheKey, DIST_T, CacheKeyHash> cache;
DIST_T GetOrCompute(
const Unit* unit,
const Coords& target,
const ActionPointDistances* notBravingApd,
const ActionPointDistances* bravingApd,
const HexMap* hexMap) const;
};
/// Calculate score for FLEE strategy
/// Returns negative score based on fleeing units
auto FleeStrategyScoreForState(const GameStateW& gameState, PlayerId playerId) -> ScoreValue;
/// Calculate attacker multiplier based on distance to priority targets
/// This is used to weight attacker units by their proximity to objectives
auto AttackerMultiplierForTargetDistance(
const Unit* attackingUnit,
const std::vector<TargetAndAttackLocations>& priorityList,
const std::vector<const Unit*>& occupants,
const HexMap* map,
const BattalionTypeSPtr& battType,
const ActionPointDistances* notBravingApd,
const ActionPointDistances* bravingApd,
bool isLateGame) -> double;
} // namespace score_calculator_internal
} // namespace shardok
#endif // EAGLE0_AI_SCORE_CALCULATOR_SHARED_UTILITIES_HPP
@@ -0,0 +1,514 @@
//
// Abstract base class for AI score calculator implementations
//
#include "AbstractAIScoreCalculator.hpp"
#include "AIScoreCalculatorSharedUtilities.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/AIWaterCrossingCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIVictoryConditionScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
using net::eagle0::shardok::storage::fb::BattalionTypeId;
using net::eagle0::shardok::storage::fb::Unit;
using score_calculator_internal::AttackerMultiplierForTargetDistance;
using score_calculator_internal::CAPTURED_UNIT_SCORE;
using score_calculator_internal::CAPTURED_VIP_SCORE;
using score_calculator_internal::EffectiveDistanceCache;
using score_calculator_internal::UnitsScoreComponents;
auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
const GameStateW &gameState,
int roundsRemaining,
bool attackerWantsCastles,
bool defenderShouldScatter,
const vector<TargetPriorityList> &attackerTargetPriorities,
const MapId &mapId) const -> UnitsScoreComponents {
// Cache frequently accessed FlatBuffer fields to avoid repeated offset calculations
const auto *gameStateRawPtr = gameState.Get();
const auto *cachedUnits = gameStateRawPtr->units();
const auto *cachedHexMap = gameStateRawPtr->hex_map();
const int16_t cachedRowCount = cachedHexMap->row_count();
const int16_t cachedColumnCount = cachedHexMap->column_count();
const int cachedCurrentRound = gameStateRawPtr->current_round();
bool isLateGame = cachedCurrentRound > 18; // Inline IsLateGame for efficiency
// APDCache now has built-in thread-local caching - no need for PreCachedAPDs
ActionPoints braveWaterCost = GetBraveWaterCost();
// Memoization cache for EffectiveDistance calls
EffectiveDistanceCache distanceCache;
std::vector<const Unit *> attackerUnits{};
std::vector<const Unit *> defenderUnits{};
// Pre-allocate vectors based on estimated unit ratios to avoid reallocations
const size_t estimatedUnitCount = cachedUnits->size();
attackerUnits.reserve(estimatedUnitCount - 1);
defenderUnits.reserve(estimatedUnitCount - 1);
double attackerUnitsValue = 0;
double defenderUnitsValue = 0;
// Early return for empty game states
if (cachedUnits->size() == 0) { return UnitsScoreComponents{0.0, 0.0}; }
auto occupants = Occupants(*cachedUnits, cachedRowCount, cachedColumnCount);
for (const Unit *unit : *cachedUnits) {
const auto *pi = PlayerInfoForPid(gameState, unit->player_id());
if (pi == nullptr) { continue; }
switch (unit->status()) {
case net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT: {
if (pi->is_defender()) {
defenderUnits.push_back(unit);
} else {
attackerUnits.push_back(unit);
}
break;
}
case net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT: {
double thisScore = unit->has_attached_hero() && unit->attached_hero().is_vip()
? CAPTURED_VIP_SCORE
: CAPTURED_UNIT_SCORE;
if (pi->is_defender()) {
defenderUnitsValue += thisScore;
} else {
attackerUnitsValue += thisScore;
}
break;
}
case net::eagle0::shardok::storage::fb::UnitStatus_DESTROYED_SUMMONED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_FLED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
throw ShardokInternalErrorException("Unknown unit status");
}
}
double defenderAdvantage = 1.0 + static_cast<double>(cachedCurrentRound) / 31.0;
// Can we cache this somehow, it won't usually change within your turn
auto attackLocationsForAttacker = GetAlCache()->CachedLocations(defenderUnits, isLateGame);
const auto &locationsCausingDanger = attackLocationsForAttacker.AllLocations();
// Process attacker units using cached ActionPointDistances
for (const Unit *unit : attackerUnits) {
const int battTypeId = unit->battalion().type();
// Cache battalion type reference to avoid shared_ptr atomic operations
const auto &battalionType = GetBattalionType(static_cast<BattalionTypeId>(battTypeId));
// Cache APD lookups - same battalion type is used multiple times below
const auto *notBravingApd =
GetApdCache()->GetRaw(cachedHexMap, mapId, battalionType, false);
const auto *bravingApd = battalionType->allowsBraveWater ? GetApdCache()->GetRaw(
cachedHexMap,
mapId,
battalionType,
true,
braveWaterCost)
: nullptr;
const auto &priorityList = std::ranges::find_if(
attackerTargetPriorities,
[&unit](const TargetPriorityList &tpl) {
return tpl.attackingUnitId == unit->unit_id();
});
// If there are any tiles being targeted, give this unit a multiplier based on how close
// they are to being able to attack it
double distanceMultiplier = priorityList == end(attackerTargetPriorities)
? 1.0
: AttackerMultiplierForTargetDistance(
unit,
priorityList->priorityOrder,
occupants,
cachedHexMap,
battalionType,
notBravingApd,
bravingApd,
isLateGame);
auto uv = UnitValue(
unit,
true,
attackerUnits,
attackerWantsCastles,
/* includeCastleBonus=*/true,
defenderUnits,
cachedHexMap,
roundsRemaining,
attackLocationsForAttacker,
locationsCausingDanger,
notBravingApd,
GetMeteorRange(),
GetMeteorCastVigorCost());
attackerUnitsValue += distanceMultiplier * uv;
}
auto attackLocationsForDefender = GetAlCache()->CachedLocations(attackerUnits, isLateGame);
const auto &locationsCausingDangerForAttacker = attackLocationsForDefender.AllLocations();
for (const Unit *unit : defenderUnits) {
auto defenderUnitId = unit->unit_id();
const int battTypeId = unit->battalion().type();
// Cache battalion type reference to avoid shared_ptr atomic operations
const auto &battalionType = GetBattalionType(static_cast<BattalionTypeId>(battTypeId));
// Cache APD lookups for this defender unit
const auto *defenderNotBravingApd =
GetApdCache()->GetRaw(cachedHexMap, mapId, battalionType, false);
auto dv = UnitValue(
unit,
false,
attackerUnits,
attackerWantsCastles,
/* includeCastleBonus=*/!defenderShouldScatter,
defenderUnits,
cachedHexMap,
roundsRemaining,
attackLocationsForDefender,
locationsCausingDangerForAttacker,
defenderNotBravingApd,
GetMeteorRange(),
GetMeteorCastVigorCost());
double distanceMultiplier = 1.0;
// If the defender is trying to scatter, than we want to be as far away from the nearest
// attacker as possible, AND as far away from the nearest friendly as possible
if (unit->location().row() > -1 && defenderShouldScatter) {
CoordsSet myLocationSet(cachedHexMap);
myLocationSet.Add(unit->location());
DIST_T closestDistanceToEnemy = 999;
for (const auto &attackerUnit : attackerUnits) {
const int attackerBattTypeId = attackerUnit->battalion().type();
// Cache attacker battalion type reference in nested loop
const auto &attackerBattalionType =
GetBattalionType(static_cast<BattalionTypeId>(attackerBattTypeId));
const DIST_T thisDistance = distanceCache.GetOrCompute(
attackerUnit,
unit->location(),
GetApdCache()->GetRaw(cachedHexMap, mapId, attackerBattalionType, false),
attackerBattalionType->allowsBraveWater ? GetApdCache()->GetRaw(
cachedHexMap,
mapId,
attackerBattalionType,
true,
braveWaterCost)
: nullptr,
cachedHexMap);
if (thisDistance < closestDistanceToEnemy) {
closestDistanceToEnemy = thisDistance;
}
}
// If the best we can do puts us very close to the enemy, and the unit is almost
// destroyed, return a negative value; better to flee
if (unit->can_flee() && closestDistanceToEnemy < 5 && unit->battalion().size() < 10) {
distanceMultiplier = -1;
} else {
DIST_T closestDistanceToFriendly = 1;
if (defenderUnits.size() > 1) {
for (const auto &defenderUnit : defenderUnits) {
if (defenderUnit->unit_id() != defenderUnitId) {
const int defenderBattTypeId = defenderUnit->battalion().type();
// Cache defender battalion type reference in nested loop
const auto &defenderBattalionType = GetBattalionType(
static_cast<BattalionTypeId>(defenderBattTypeId));
const DIST_T thisDistance = distanceCache.GetOrCompute(
defenderUnit,
unit->location(),
GetApdCache()->GetRaw(
cachedHexMap,
mapId,
defenderBattalionType,
false),
defenderBattalionType->allowsBraveWater
? GetApdCache()->GetRaw(
cachedHexMap,
mapId,
defenderBattalionType,
true,
braveWaterCost)
: nullptr,
cachedHexMap);
if (thisDistance < closestDistanceToEnemy) {
closestDistanceToFriendly = thisDistance;
}
}
}
}
distanceMultiplier =
(closestDistanceToEnemy + closestDistanceToFriendly / 5.0) / 5.0;
}
}
defenderUnitsValue += distanceMultiplier * dv;
}
defenderUnitsValue *= defenderAdvantage;
return UnitsScoreComponents{attackerUnitsValue, defenderUnitsValue};
}
auto AbstractAIScoreCalculator::FindDefenderPlayerInfo(const GameStateW &gameState) const
-> const net::eagle0::shardok::storage::fb::PlayerInfo * {
const auto *playerInfos = gameState->player_infos();
if (playerInfos == nullptr) { return nullptr; }
for (const net::eagle0::shardok::storage::fb::PlayerInfo *pi : *playerInfos) {
if (pi->is_defender()) return pi;
}
return nullptr;
}
auto AbstractAIScoreCalculator::CalculateAttackerVictoryConditionScore(
const GameStateW &gameState,
const AIStrategy &attackerStrategy,
const CoordsSet &castleCoords) const -> ScoreValue {
ScoreValue victoryConditionTotal = 0.0;
const auto *playerInfos = gameState->player_infos();
if (playerInfos == nullptr) { return 0.0; }
for (const net::eagle0::shardok::storage::fb::PlayerInfo *pi : *playerInfos) {
if (pi->is_defender()) continue;
switch (attackerStrategy.strategyType) {
case AIStrategy::STRATEGY_CROSS_RIVERS:
victoryConditionTotal += WaterCrossingScore(
pi->player_id(),
[this](BattalionTypeId typeId) { return GetBattalionType(typeId); },
gameState,
castleCoords,
attackerStrategy.targetLocations,
GetApdCache());
break;
case AIStrategy::STRATEGY_ATTACK_CASTLES:
case AIStrategy::STRATEGY_ATTACK_UNITS:
// already factored into AttackerUnitsScore
break;
case AIStrategy::STRATEGY_HOLD_CASTLES:
victoryConditionTotal += AttackerHoldsCriticalTilesVictoryScore(
gameState,
castleCoords,
pi,
GetApdCache(),
GetAlCache(),
[this](BattalionTypeId typeId) { return GetBattalionType(typeId); },
GetBraveWaterCost());
break;
case AIStrategy::STRATEGY_SCATTER:
throw ShardokInternalErrorException("Attacker cannot use ScatterStrategy");
case AIStrategy::STRATEGY_FLEE:
// FLEE strategy is handled specially by each subclass
// Return 0 here and let the caller handle it
return 0.0;
}
}
return victoryConditionTotal;
}
auto AbstractAIScoreCalculator::DefenderScoreForState(
const GameStateW &gameState,
const AIStrategy &defenderStrategy,
const CoordsSet &castleCoords,
const int roundsRemaining) const -> ScoreValue {
// Check for terminal states
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
const auto *winningIds = gameState->status()->winning_shardok_ids();
const auto *playerInfos = gameState->player_infos();
if (winningIds != nullptr && playerInfos != nullptr) {
for (const PlayerId winningPid : *winningIds) {
if (winningPid < 0) continue;
if (defenderStrategy.strategyType == AIStrategy::STRATEGY_FLEE) {
return InterpretDefenderOutcome(GameOutcome::FLEE_OUTCOME);
}
if (playerInfos->Get(winningPid)->is_defender()) {
return InterpretDefenderOutcome(GameOutcome::DEFENDER_VICTORY);
}
return InterpretDefenderOutcome(GameOutcome::ATTACKER_VICTORY);
}
}
return InterpretDefenderOutcome(GameOutcome::ATTACKER_VICTORY);
}
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
return InterpretDefenderOutcome(GameOutcome::DRAW);
}
// Delegate to strategy-specific methods (implemented by subclasses)
switch (defenderStrategy.strategyType) {
case AIStrategy::STRATEGY_ATTACK_CASTLES:
throw ShardokInternalErrorException("Defender cannot use AttackCastlesStrategy");
case AIStrategy::STRATEGY_ATTACK_UNITS:
throw ShardokInternalErrorException("Defender cannot use AttackUnitsStrategy");
case AIStrategy::STRATEGY_CROSS_RIVERS:
throw ShardokInternalErrorException("Defender cannot use CrossRiversStrategy");
case AIStrategy::STRATEGY_HOLD_CASTLES:
return DefenderHoldCastlesStrategyScoreForState(
gameState,
castleCoords,
roundsRemaining);
case AIStrategy::STRATEGY_SCATTER:
return DefenderScatterStrategyScoreForState(gameState, roundsRemaining);
case AIStrategy::STRATEGY_FLEE: return DefenderFleeStrategyScoreForState(gameState);
}
throw ShardokInternalErrorException("Escaped AIStrategy switch");
}
auto AbstractAIScoreCalculator::DefenderScatterStrategyScoreForState(
const GameStateW &gameState,
const int roundsRemaining) const -> ScoreValue {
// Check for terminal states
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
const auto *winningIds = gameState->status()->winning_shardok_ids();
const auto *playerInfos = gameState->player_infos();
if (winningIds != nullptr && playerInfos != nullptr) {
for (const PlayerId winningPid : *winningIds) {
if (winningPid < 0) continue;
if (playerInfos->Get(winningPid)->is_defender()) {
return InterpretDefenderOutcome(GameOutcome::DEFENDER_VICTORY);
}
return InterpretDefenderOutcome(GameOutcome::ATTACKER_VICTORY);
}
}
return InterpretDefenderOutcome(GameOutcome::DEFENDER_VICTORY);
}
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
return InterpretDefenderOutcome(GameOutcome::DRAW);
}
// Get units score components
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
const auto components = CalculateUnitsScoreComponents(
gameState,
roundsRemaining,
/* attackerWantsCastles=*/false,
/* defenderShouldScatter=*/true,
{},
mapId);
// Combine using subclass-specific logic
return CombineDefenderScatterScores(components);
}
auto AbstractAIScoreCalculator::DefenderHoldCastlesStrategyScoreForState(
const GameStateW &gameState,
const CoordsSet &castleCoords,
const int roundsRemaining) const -> ScoreValue {
// Get units score components
const auto components = CalculateUnitsScoreComponents(
gameState,
roundsRemaining,
/* attackerWantsCastles=*/true,
/* defenderShouldScatter=*/false,
{},
ActionPointDistancesCache::GetMapId(gameState->hex_map()));
// Get victory condition score from defender's perspective
const PlayerInfo *defenderPi = FindDefenderPlayerInfo(gameState);
// Handle null defenderPi gracefully
if (defenderPi == nullptr) {
// No defender player found - return neutral score using components only
return CombineDefenderHoldCastlesScores(components, 0.0, roundsRemaining);
}
const double victoryConditionScore =
DefenderHoldsCriticalTilesVictoryScore(gameState, castleCoords, defenderPi);
// Combine using subclass-specific logic
return CombineDefenderHoldCastlesScores(components, victoryConditionScore, roundsRemaining);
}
auto AbstractAIScoreCalculator::AttackerScoreForState(
const GameStateW &gameState,
const AIStrategy &attackerStrategy,
const CoordsSet &castleCoords,
const int roundsRemaining) const -> ScoreValue {
// Check for terminal states
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
const auto *winningIds = gameState->status()->winning_shardok_ids();
const auto *playerInfos = gameState->player_infos();
if (winningIds != nullptr && playerInfos != nullptr) {
for (const PlayerId winningPid : *winningIds) {
if (winningPid < 0) continue;
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) {
return InterpretAttackerOutcome(GameOutcome::FLEE_OUTCOME);
}
if (playerInfos->Get(winningPid)->is_defender()) {
return InterpretAttackerOutcome(GameOutcome::DEFENDER_VICTORY);
}
return InterpretAttackerOutcome(GameOutcome::ATTACKER_VICTORY);
}
}
return InterpretAttackerOutcome(GameOutcome::ATTACKER_VICTORY);
}
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
return InterpretAttackerOutcome(GameOutcome::DRAW);
}
// Handle FLEE strategy specially
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) {
return AttackerFleeStrategyScoreForState(gameState);
}
// Edge case: no units means call subclass method to handle neutral state
// This is defensive - some subclasses may want special handling
const auto *units = gameState->units();
if (units == nullptr || units->size() == 0) {
// Call CombineAttackerScores with all zeros
return CombineAttackerScores(UnitsScoreComponents{0.0, 0.0}, 0.0, roundsRemaining);
}
// Get units score components
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
const auto components = CalculateUnitsScoreComponents(
gameState,
roundsRemaining,
attackerStrategy.strategyType == AIStrategy::STRATEGY_HOLD_CASTLES,
/* defenderShouldScatter=*/false,
attackerStrategy.targetPriorities,
mapId);
// Calculate victory condition score
const ScoreValue victoryConditionTotal =
CalculateAttackerVictoryConditionScore(gameState, attackerStrategy, castleCoords);
// Combine scores using subclass-specific logic
// Standard: uses difference and multiplier
// Normalized: uses normalization formula
return CombineAttackerScores(components, victoryConditionTotal, roundsRemaining);
}
} // namespace shardok
@@ -0,0 +1,176 @@
//
// Abstract base class for AI score calculator implementations
// This file is private to the ai/score package
//
#ifndef EAGLE0_ABSTRACT_AI_SCORE_CALCULATOR_HPP
#define EAGLE0_ABSTRACT_AI_SCORE_CALCULATOR_HPP
#include <memory>
#include <unordered_map>
#include "AIScoreCalculatorSharedUtilities.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/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state_generated.h"
namespace shardok {
using net::eagle0::shardok::storage::fb::BattalionTypeId;
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
using ALCache = std::unique_ptr<AttackLocationsCache>;
using score_calculator_internal::UnitsScoreComponents;
/// Enum representing terminal game outcomes for score interpretation
enum class GameOutcome {
ATTACKER_VICTORY,
DEFENDER_VICTORY,
DRAW,
FLEE_OUTCOME // Special outcome for FLEE strategy
};
/// Abstract base class providing shared functionality for AI score calculators.
/// Contains common member variables, accessor methods, and shared helper logic.
/// This class is private to the ai/score package.
class AbstractAIScoreCalculator : public AIScoreCalculator {
protected:
AbstractAIScoreCalculator(
int maxRounds,
ActionPoints braveWaterCost,
int meteorRange,
double meteorCastVigorCost,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
std::vector<BattalionTypeSPtr> battalionTypes,
const APDCache &apdCache,
const ALCache &alCache)
: maxRounds_(maxRounds),
braveWaterCost_(braveWaterCost),
meteorRange_(meteorRange),
meteorCastVigorCost_(meteorCastVigorCost),
minimumFleeOddsThreshold_(minimumFleeOddsThreshold),
desperateFleeThreshold_(desperateFleeThreshold),
battalionTypes_(std::move(battalionTypes)),
apdCache_(apdCache),
alCache_(alCache) {}
// Protected accessor methods available to subclasses
[[nodiscard]] inline auto GetBattalionType(BattalionTypeId typeId) const
-> const BattalionTypeSPtr & {
return battalionTypes_[typeId];
}
[[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 GetMaxRounds() const -> int { return maxRounds_; }
[[nodiscard]] auto GetMeteorRange() const -> int { return meteorRange_; }
[[nodiscard]] auto GetMeteorCastVigorCost() const -> double { return meteorCastVigorCost_; }
[[nodiscard]] auto GetMinimumFleeOddsThreshold() const -> int {
return minimumFleeOddsThreshold_;
}
[[nodiscard]] auto GetDesperateFleeThreshold() const -> int { return desperateFleeThreshold_; }
// Protected helper method that both subclasses can use
// Returns separate attacker and defender unit values
[[nodiscard]] auto CalculateUnitsScoreComponents(
const GameStateW &gameState,
int roundsRemaining,
bool attackerWantsCastles,
bool defenderShouldScatter,
const vector<TargetPriorityList> &attackerTargetPriorities,
const MapId &mapId) const -> UnitsScoreComponents;
// Helper to find the defender PlayerInfo
[[nodiscard]] auto FindDefenderPlayerInfo(const GameStateW &gameState) const
-> const net::eagle0::shardok::storage::fb::PlayerInfo *;
// Calculate victory condition score for attacker strategies
// Returns the raw victory condition score (not yet combined with units score)
[[nodiscard]] auto CalculateAttackerVictoryConditionScore(
const GameStateW &gameState,
const AIStrategy &attackerStrategy,
const CoordsSet &castleCoords) const -> ScoreValue;
// Pure virtual methods for subclasses to interpret game outcomes
[[nodiscard]] virtual auto InterpretDefenderOutcome(GameOutcome outcome) const
-> ScoreValue = 0;
[[nodiscard]] virtual auto InterpretAttackerOutcome(GameOutcome outcome) const
-> ScoreValue = 0;
// Shared implementation of DefenderScoreForState that uses InterpretDefenderOutcome
[[nodiscard]] auto DefenderScoreForState(
const GameStateW &gameState,
const AIStrategy &defenderStrategy,
const CoordsSet &castleCoords,
int roundsRemaining) const -> ScoreValue;
// Shared implementations that delegate to pure virtual methods
[[nodiscard]] auto DefenderScatterStrategyScoreForState(
const GameStateW &gameState,
int roundsRemaining) const -> ScoreValue;
[[nodiscard]] auto DefenderHoldCastlesStrategyScoreForState(
const GameStateW &gameState,
const CoordsSet &castleCoords,
int roundsRemaining) const -> ScoreValue;
// Pure virtual methods for combining defender scores
[[nodiscard]] virtual auto CombineDefenderScatterScores(
const UnitsScoreComponents &components) const -> ScoreValue = 0;
[[nodiscard]] virtual auto CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
int roundsRemaining) const -> ScoreValue = 0;
[[nodiscard]] virtual auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue = 0;
// Shared implementation of AttackerScoreForState that uses InterpretAttackerOutcome
[[nodiscard]] auto AttackerScoreForState(
const GameStateW &gameState,
const AIStrategy &attackerStrategy,
const CoordsSet &castleCoords,
int roundsRemaining) const -> ScoreValue;
// Pure virtual method for attacker flee strategy scoring
[[nodiscard]] virtual auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
-> 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
[[nodiscard]] virtual auto CombineAttackerScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
int roundsRemaining) const -> ScoreValue = 0;
private:
// Scalar settings extracted from SettingsGetter
int maxRounds_;
ActionPoints braveWaterCost_;
int meteorRange_;
double meteorCastVigorCost_;
int minimumFleeOddsThreshold_;
int desperateFleeThreshold_;
// Battalion type lookup vector (indexed by BattalionTypeId)
std::vector<BattalionTypeSPtr> battalionTypes_;
// Caches (stored as references)
const APDCache &apdCache_;
const ALCache &alCache_;
};
} // namespace shardok
#endif // EAGLE0_ABSTRACT_AI_SCORE_CALCULATOR_HPP
@@ -0,0 +1,50 @@
load("//tools:copts.bzl", "COPTS")
# Abstract base class for score calculators
cc_library(
name = "abstract_ai_score_calculator",
srcs = ["AbstractAIScoreCalculator.cpp"],
hdrs = ["AbstractAIScoreCalculator.hpp"],
copts = COPTS,
visibility = ["//src/main/cpp/net/eagle0/shardok/ai/score:__pkg__"], # Private to score package
deps = [
":ai_score_calculator_shared_utilities",
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_locations",
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_utilities",
"//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:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_victory_condition_score_calculator",
"//src/main/cpp/net/eagle0/shardok/library:battalion_type",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
],
)
# Private utilities shared between score calculators
cc_library(
name = "ai_score_calculator_shared_utilities",
srcs = ["AIScoreCalculatorSharedUtilities.cpp"],
hdrs = ["AIScoreCalculatorSharedUtilities.hpp"],
copts = COPTS,
visibility = ["//src/main/cpp/net/eagle0/shardok/ai/score:__pkg__"], # Private to score package
deps = [
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library:battalion_type",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
"//src/main/flatbuffer/net/eagle0/shardok/storage:hex_map_cc_fbs",
"//src/main/flatbuffer/net/eagle0/shardok/storage:unit_cc_fbs",
"@gtl",
],
)
@@ -0,0 +1,106 @@
//
// AI Battle Simulator Configuration Loader Implementation
//
#include "AiBattleConfig.hpp"
#include <google/protobuf/util/json_util.h>
#include <fstream>
#include <iostream>
#include <sstream>
namespace shardok {
namespace ai_battle_simulator {
using net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType;
using net::eagle0::shardok::ai_battle_simulator::PlayerConfig;
using net::eagle0::shardok::ai_battle_simulator::UnitConfig;
std::unique_ptr<BattleConfigProto> AiBattleConfigLoader::LoadFromJsonFile(
const std::string& filepath) {
std::ifstream file(filepath);
if (!file.is_open()) {
std::cerr << "Failed to open config file: " << filepath << std::endl;
return nullptr;
}
std::stringstream buffer;
buffer << file.rdbuf();
return LoadFromJsonString(buffer.str());
}
std::unique_ptr<BattleConfigProto> AiBattleConfigLoader::LoadFromJsonString(
const std::string& jsonString) {
auto config = std::make_unique<BattleConfigProto>();
google::protobuf::util::JsonParseOptions options;
options.ignore_unknown_fields = false;
const auto status =
google::protobuf::util::JsonStringToMessage(jsonString, config.get(), options);
if (!status.ok()) {
std::cerr << "Failed to parse JSON config: " << status.message() << std::endl;
return nullptr;
}
return config;
}
std::unique_ptr<BattleConfigProto> AiBattleConfigLoader::CreateDefaultPerfConfig(int month) {
auto config = std::make_unique<BattleConfigProto>();
// 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
// Attacker configuration - 6 Longbowmen units with professions 1-6
PlayerConfig* attacker = config->mutable_attacker();
attacker->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
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->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);
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->mutable_battalion()->set_size(1000); // Match client battalion size
}
return config;
}
std::string AiBattleConfigLoader::ToJsonString(const BattleConfigProto& config) {
std::string jsonString;
google::protobuf::util::JsonPrintOptions options;
options.add_whitespace = true;
options.always_print_fields_with_no_presence = true;
options.preserve_proto_field_names = true;
const auto status = google::protobuf::util::MessageToJsonString(config, &jsonString, options);
if (!status.ok()) {
std::cerr << "Failed to convert config to JSON: " << status.message() << std::endl;
return "";
}
return jsonString;
}
} // namespace ai_battle_simulator
} // namespace shardok
@@ -0,0 +1,47 @@
//
// AI Battle Simulator Configuration Loader
//
#ifndef EAGLE0_AI_BATTLE_CONFIG_HPP
#define EAGLE0_AI_BATTLE_CONFIG_HPP
#include <memory>
#include <string>
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/ai_battle_simulator/ai_battle_config.pb.h"
#pragma clang diagnostic pop
namespace shardok {
namespace ai_battle_simulator {
using BattleConfigProto = net::eagle0::shardok::ai_battle_simulator::BattleConfig;
class AiBattleConfigLoader {
public:
// Load battle configuration from a JSON file
// Returns nullptr if loading fails
[[nodiscard]] static std::unique_ptr<BattleConfigProto> LoadFromJsonFile(
const std::string& filepath);
// Load battle configuration from a JSON string
// Returns nullptr if parsing fails
[[nodiscard]] static std::unique_ptr<BattleConfigProto> LoadFromJsonString(
const std::string& jsonString);
// Generate a default "Perf" configuration matching Unity's Custom Battle Perf setup
// - 6 units per side (Heavy Infantry, professions 1-6)
// - Map: Alah
// - Month: configurable (default 4 = April)
// - Both players use Iterative Deepening AI
[[nodiscard]] static std::unique_ptr<BattleConfigProto> CreateDefaultPerfConfig(int month = 4);
// Convert battle configuration to JSON string for saving/debugging
[[nodiscard]] static std::string ToJsonString(const BattleConfigProto& config);
};
} // namespace ai_battle_simulator
} // namespace shardok
#endif // EAGLE0_AI_BATTLE_CONFIG_HPP
@@ -0,0 +1,565 @@
//
// AI Battle Simulator Implementation
//
#include "AiBattleSimulator.hpp"
#include <iostream>
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.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/BattalionTypeRegistrar.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/common/player_info.pb.h"
#pragma clang diagnostic pop
namespace shardok {
namespace ai_battle_simulator {
namespace {
constexpr PlayerId ATTACKER_ID = 0;
constexpr PlayerId DEFENDER_ID = 1;
// Default values for unit configuration - matching client-initiated battles
constexpr double DEFAULT_BATTALION_ARMAMENT = 102.0;
constexpr double DEFAULT_BATTALION_TRAINING = 102.0;
constexpr double DEFAULT_BATTALION_MORALE = 76.0;
constexpr int DEFAULT_HERO_STRENGTH = 102;
constexpr int DEFAULT_HERO_AGILITY = 102;
constexpr int DEFAULT_HERO_WISDOM = 102;
constexpr int DEFAULT_HERO_CHARISMA = 102;
constexpr int DEFAULT_HERO_CONSTITUTION = 102;
constexpr int DEFAULT_HERO_BRAVERY = 102;
constexpr int DEFAULT_HERO_INTEGRITY = 0;
constexpr int DEFAULT_HERO_AMBITION = 0;
constexpr int DEFAULT_HERO_VIGOR = 102;
// Convert proto AI algorithm type to ShardokAI enum
AIAlgorithmType ConvertAIAlgorithmType(
net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType protoType) {
switch (protoType) {
case net::eagle0::shardok::ai_battle_simulator::MCTS: return AIAlgorithmType::MCTS;
case net::eagle0::shardok::ai_battle_simulator::ITERATIVE_DEEPENING:
default: return AIAlgorithmType::ITERATIVE_DEEPENING;
}
}
// 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;
}
} // namespace
AiBattleSimulator::AiBattleSimulator(const BattleConfigProto& config, GameSettingsSPtr gameSettings)
: config_(config),
gameSettings_(std::move(gameSettings)) {
if (!gameSettings_) {
// Initialize default game settings
gameSettings_ = std::make_shared<GameSettings>();
auto setter = gameSettings_->GetSetter();
// Load battalion types
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
// Load settings from file
const std::string settingsPath =
FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
const std::string settingsTsv = std::string(byte_vector::FromPath(settingsPath));
TsvParser parser;
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
}
}
BattleResult AiBattleSimulator::RunBattle() {
std::cout << "Starting AI vs AI battle simulation...\n";
std::cout << " Map: " << config_.map_name() << "\n";
std::cout << " Month: " << config_.month() << "\n";
std::cout << " Max rounds: " << config_.max_rounds() << "\n";
std::cout << " Attacker AI: "
<< net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType_Name(
config_.attacker().ai_algorithm())
<< "\n";
std::cout << " Defender AI: "
<< net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType_Name(
config_.defender().ai_algorithm())
<< "\n";
// Create initial game state
auto gameState = CreateInitialGameState();
// Get hex map from game state
const auto* hexMap = gameState->hex_map();
// Create AI clients for both players
auto attackerAI = CreateAIClient(ATTACKER_ID, hexMap);
auto defenderAI = CreateAIClient(DEFENDER_ID, hexMap);
// 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;
}
// Run battle phase
std::cout << "\nStarting battle phase...\n";
return RunBattlePhase(engine, *attackerAI, *defenderAI);
}
GameStateW AiBattleSimulator::CreateInitialGameState() const {
// Load map
auto hexMapProto = LoadMap(config_.map_name());
// Create player info protos
std::vector<net::eagle0::shardok::common::PlayerInfo> playerInfoProtos;
// Attacker info
net::eagle0::shardok::common::PlayerInfo attackerInfo;
attackerInfo.set_player_id(ATTACKER_ID);
attackerInfo.set_is_defender(false);
attackerInfo.set_starting_food(1000);
attackerInfo.add_victory_conditions(
net::eagle0::shardok::common::VICTORY_CONDITION_LAST_PLAYER_STANDING);
attackerInfo.add_victory_conditions(
net::eagle0::shardok::common::VICTORY_CONDITION_HOLDS_CRITICAL_TILES);
playerInfoProtos.push_back(attackerInfo);
// Defender info
net::eagle0::shardok::common::PlayerInfo defenderInfo;
defenderInfo.set_player_id(DEFENDER_ID);
defenderInfo.set_is_defender(true);
defenderInfo.set_starting_food(1000);
defenderInfo.add_victory_conditions(
net::eagle0::shardok::common::VICTORY_CONDITION_LAST_PLAYER_STANDING);
defenderInfo.add_victory_conditions(
net::eagle0::shardok::common::VICTORY_CONDITION_WIN_AFTER_MAX_ROUNDS);
playerInfoProtos.push_back(defenderInfo);
// Create units from config
std::vector<net::eagle0::shardok::storage::fb::Unit> units;
// Attacker units
for (int i = 0; i < config_.attacker().units_size(); ++i) {
const auto& unitConfig = config_.attacker().units(i);
net::eagle0::shardok::storage::fb::Unit unit{};
unit.mutate_player_id(ATTACKER_ID);
unit.mutate_unit_id(i);
unit.mutate_eagle_player_id(ATTACKER_ID);
unit.mutable_location() = net::eagle0::shardok::storage::fb::Coords(-1, -1);
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
unit.mutate_remaining_action_points(12);
unit.mutate_hidden(false);
unit.mutate_fortified(false);
unit.mutate_can_flee(true);
unit.mutate_can_start_fire(false);
unit.mutate_can_archery(false);
unit.mutate_stun_rounds_remaining(0);
unit.mutate_commanding_unit_id(-1);
unit.mutate_targeted_unit(-1);
unit.mutate_starting_position_index(unitConfig.starting_position_index());
unit.mutate_has_moved_in_zoc(false);
unit.mutate_volleys_remaining(0);
unit.mutate_food_remaining(1000.0f);
// Battalion
net::eagle0::shardok::storage::fb::Battalion battalion;
const auto battalionTypeId =
static_cast<net::eagle0::shardok::storage::fb::BattalionTypeId>(
unitConfig.battalion_type_id());
battalion.mutate_type(battalionTypeId);
// Get battalion type to determine default capacity
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
const double defaultSize = battalionType->capacity;
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));
unit.mutable_battalion() = battalion;
// Hero
if (unitConfig.profession() > 0) {
unit.mutate_has_attached_hero(true);
net::eagle0::shardok::storage::fb::Hero hero;
hero.mutate_strength(GetOrDefault(unitConfig.hero().strength(), DEFAULT_HERO_STRENGTH));
hero.mutate_strength_xp(0);
hero.mutate_agility(GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY));
hero.mutate_agility_xp(0);
hero.mutate_wisdom(GetOrDefault(unitConfig.hero().wisdom(), DEFAULT_HERO_WISDOM));
hero.mutate_wisdom_xp(0);
hero.mutate_charisma(GetOrDefault(unitConfig.hero().charisma(), DEFAULT_HERO_CHARISMA));
hero.mutate_charisma_xp(0);
hero.mutate_constitution(
GetOrDefault(unitConfig.hero().constitution(), DEFAULT_HERO_CONSTITUTION));
hero.mutate_constitution_xp(0);
const int vigor = GetOrDefault(unitConfig.hero().vigor(), DEFAULT_HERO_VIGOR);
hero.mutate_vigor(vigor);
hero.mutate_starting_vigor(vigor);
hero.mutate_spent_vigor(0);
hero.mutate_bravery(GetOrDefault(unitConfig.hero().bravery(), DEFAULT_HERO_BRAVERY));
hero.mutate_integrity(
GetOrDefault(unitConfig.hero().integrity(), DEFAULT_HERO_INTEGRITY));
hero.mutate_ambition(GetOrDefault(unitConfig.hero().ambition(), DEFAULT_HERO_AMBITION));
hero.mutate_eagle_hero_id(i + 1);
hero.mutate_is_vip(false);
hero.mutable_profession_info().mutate_profession(
static_cast<net::eagle0::shardok::storage::fb::Profession>(
unitConfig.profession()));
hero.mutable_profession_info().mutate_meteor_cast_state(
net::eagle0::shardok::storage::fb::MultiroundMagicState_NONE);
hero.mutable_control_info().mutate_controlled_unit_id(-1);
hero.mutable_control_info().mutate_controlled_this_round(false);
unit.mutable_attached_hero() = hero;
} else {
unit.mutate_has_attached_hero(false);
}
// Initialize opponent knowledge
unit.mutable_opponent_knowledge()->Mutate(0, 0);
unit.mutable_opponent_knowledge()->Mutate(1, 0);
units.push_back(unit);
}
// Defender units
int defenderUnitIdStart = config_.attacker().units_size();
for (int i = 0; i < config_.defender().units_size(); ++i) {
const auto& unitConfig = config_.defender().units(i);
net::eagle0::shardok::storage::fb::Unit unit{};
unit.mutate_player_id(DEFENDER_ID);
unit.mutate_unit_id(defenderUnitIdStart + i);
unit.mutate_eagle_player_id(DEFENDER_ID);
unit.mutable_location() = net::eagle0::shardok::storage::fb::Coords(-1, -1);
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
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_start_fire(false);
unit.mutate_can_archery(false);
unit.mutate_stun_rounds_remaining(0);
unit.mutate_commanding_unit_id(-1);
unit.mutate_targeted_unit(-1);
unit.mutate_starting_position_index(unitConfig.starting_position_index());
unit.mutate_has_moved_in_zoc(false);
unit.mutate_volleys_remaining(0);
unit.mutate_food_remaining(1000.0f);
// Battalion
net::eagle0::shardok::storage::fb::Battalion battalion;
const auto battalionTypeId =
static_cast<net::eagle0::shardok::storage::fb::BattalionTypeId>(
unitConfig.battalion_type_id());
battalion.mutate_type(battalionTypeId);
// Get battalion type to determine default capacity
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
const double defaultSize = battalionType->capacity;
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));
unit.mutable_battalion() = battalion;
// Hero
if (unitConfig.profession() > 0) {
unit.mutate_has_attached_hero(true);
net::eagle0::shardok::storage::fb::Hero hero;
hero.mutate_strength(GetOrDefault(unitConfig.hero().strength(), DEFAULT_HERO_STRENGTH));
hero.mutate_strength_xp(0);
hero.mutate_agility(GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY));
hero.mutate_agility_xp(0);
hero.mutate_wisdom(GetOrDefault(unitConfig.hero().wisdom(), DEFAULT_HERO_WISDOM));
hero.mutate_wisdom_xp(0);
hero.mutate_charisma(GetOrDefault(unitConfig.hero().charisma(), DEFAULT_HERO_CHARISMA));
hero.mutate_charisma_xp(0);
hero.mutate_constitution(
GetOrDefault(unitConfig.hero().constitution(), DEFAULT_HERO_CONSTITUTION));
hero.mutate_constitution_xp(0);
const int vigor = GetOrDefault(unitConfig.hero().vigor(), DEFAULT_HERO_VIGOR);
hero.mutate_vigor(vigor);
hero.mutate_starting_vigor(vigor);
hero.mutate_spent_vigor(0);
hero.mutate_bravery(GetOrDefault(unitConfig.hero().bravery(), DEFAULT_HERO_BRAVERY));
hero.mutate_integrity(
GetOrDefault(unitConfig.hero().integrity(), DEFAULT_HERO_INTEGRITY));
hero.mutate_ambition(GetOrDefault(unitConfig.hero().ambition(), DEFAULT_HERO_AMBITION));
hero.mutate_eagle_hero_id(defenderUnitIdStart + i + 1);
hero.mutate_is_vip(false);
hero.mutable_profession_info().mutate_profession(
static_cast<net::eagle0::shardok::storage::fb::Profession>(
unitConfig.profession()));
hero.mutable_profession_info().mutate_meteor_cast_state(
net::eagle0::shardok::storage::fb::MultiroundMagicState_NONE);
hero.mutable_control_info().mutate_controlled_unit_id(-1);
hero.mutable_control_info().mutate_controlled_this_round(false);
unit.mutable_attached_hero() = hero;
} else {
unit.mutate_has_attached_hero(false);
}
// Initialize opponent knowledge
unit.mutable_opponent_knowledge()->Mutate(0, 0);
unit.mutable_opponent_knowledge()->Mutate(1, 0);
units.push_back(unit);
}
// Create game state
const bool isWinter = (config_.month() >= 10 || config_.month() <= 2);
return shardok::fb::SetupInitialGameState(
"ai_battle_sim",
hexMapProto,
playerInfoProtos,
units,
config_.month(),
isWinter,
gameSettings_->GetGetter());
}
std::unique_ptr<ShardokAIClient> AiBattleSimulator::CreateAIClient(
PlayerId playerId,
const HexMap* hexMap) const {
const auto& playerConfig = (playerId == ATTACKER_ID) ? config_.attacker() : config_.defender();
const bool isDefender = (playerId == DEFENDER_ID);
AIAlgorithmType algorithmType = ConvertAIAlgorithmType(playerConfig.ai_algorithm());
return std::make_unique<ShardokAIClient>(
playerId,
isDefender,
hexMap,
gameSettings_->GetGetter(),
algorithmType);
}
BattleResult AiBattleSimulator::RunSetupPhase(
ShardokEngine& engine,
ShardokAIClient& attackerAI,
ShardokAIClient& defenderAI) {
int commandsExecuted = 0;
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()) {
std::cout << "No commands available during setup for player " << (int)currentPlayer
<< "\n";
break;
}
// Choose which AI to use
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
// Get AI decision
auto choiceResults = activeAI.ChooseCommandIndex(engine);
// Apply command
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
commandsExecuted++;
// Check if game ended unexpectedly
if (engine.GameIsOver()) {
return CreateResultFromGameState(engine.GetCurrentGameState(), 0, commandsExecuted);
}
}
std::cout << "Setup phase complete. Commands executed: " << commandsExecuted << "\n";
// Return a "not finished" result
BattleResult result;
result.winner = -1;
result.totalRounds = 0;
result.totalCommands = commandsExecuted;
result.endReason = BattleResult::EndReason::DRAW; // Temporary placeholder
result.description = "Setup phase completed";
return result;
}
BattleResult AiBattleSimulator::RunBattlePhase(
ShardokEngine& engine,
ShardokAIClient& attackerAI,
ShardokAIClient& defenderAI) {
int totalCommands = 0;
int currentRound = 1;
while (!engine.GameIsOver() && currentRound <= config_.max_rounds()) {
auto currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
if (availableCommands.empty()) {
std::cout << "No commands available for player " << (int)currentPlayer << "\n";
break;
}
// Choose which AI to use
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
// Get AI decision
auto choiceResults = activeAI.ChooseCommandIndex(engine);
// Apply command
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
totalCommands++;
// Check round progression
auto newState = engine.GetCurrentGameState();
if (newState->current_round() > currentRound) {
std::cout << "Round " << currentRound
<< " completed. Commands this round: " << totalCommands << "\n";
currentRound = newState->current_round();
}
}
std::cout << "Battle phase complete. Total rounds: " << currentRound
<< ", Total commands: " << totalCommands << "\n";
return CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
}
bool AiBattleSimulator::IsGameOver(const GameStateW& state) const {
const auto stateValue = state->status()->state();
return stateValue == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY ||
stateValue == net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW;
}
BattleResult AiBattleSimulator::CreateResultFromGameState(
const GameStateW& state,
int totalRounds,
int totalCommands) const {
BattleResult result;
result.totalRounds = totalRounds;
result.totalCommands = totalCommands;
const auto* status = state->status();
const auto stateValue = status->state();
// Determine winner and end reason
if (stateValue == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
// Extract winner from winning_shardok_ids
const auto* winningIds = status->winning_shardok_ids();
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()) {
result.endReason = BattleResult::EndReason::CRITICAL_TILES_CONTROLLED;
result.description = "Attacker won by controlling critical tiles";
} else {
result.endReason = BattleResult::EndReason::LAST_PLAYER_STANDING;
result.description = "Attacker won by eliminating all defenders";
}
} else if (result.winner == DEFENDER_ID) {
// Defender won
if (totalRounds >= config_.max_rounds()) {
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
result.description = "Defender won by surviving maximum rounds";
} else {
result.endReason = BattleResult::EndReason::LAST_PLAYER_STANDING;
result.description = "Defender won by eliminating all attackers";
}
} else {
result.winner = -1;
result.endReason = BattleResult::EndReason::DRAW;
result.description = "Game ended with unknown winner";
}
} else {
result.winner = -1;
result.endReason = BattleResult::EndReason::DRAW;
result.description = "Game ended with no winner";
}
} else if (stateValue == net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
result.winner = -1;
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";
}
// Collect surviving units (for winner, or all units if draw)
const auto* units = state->units();
if (units) {
for (const auto* unit : *units) {
if (!unit) { continue; }
// Only include units that are still alive
// Based on GameStateFilter.cpp logic
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; }
// For victories, only include winner's units; for draws, include all
if (result.winner != -1 && unit->player_id() != result.winner) { continue; }
SurvivingUnit survivor;
survivor.unitId = unit->unit_id();
survivor.battalionSize = unit->battalion().size();
survivor.profession = unit->has_attached_hero()
? unit->attached_hero().profession_info().profession()
: 0;
// Get battalion type name
const auto& battalionType =
gameSettings_->GetGetter().GetBattalionType(unit->battalion().type());
survivor.battalionTypeName = battalionType->name;
result.survivingUnits.push_back(survivor);
}
}
return result;
}
} // namespace ai_battle_simulator
} // namespace shardok
@@ -0,0 +1,118 @@
//
// AI Battle Simulator - Runs AI vs AI battles to completion
//
#ifndef EAGLE0_AI_BATTLE_SIMULATOR_HPP
#define EAGLE0_AI_BATTLE_SIMULATOR_HPP
#include <memory>
#include <string>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/hex_map.hpp"
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/ai_battle_simulator/ai_battle_config.pb.h"
#pragma clang diagnostic pop
namespace shardok {
// Forward declarations
class ShardokAIClient;
class ShardokEngine;
// Type alias for hex map
using HexMap = net::eagle0::shardok::storage::fb::HexMap;
namespace ai_battle_simulator {
using BattleConfigProto = net::eagle0::shardok::ai_battle_simulator::BattleConfig;
// Information about a surviving unit
struct SurvivingUnit {
int unitId;
std::string battalionTypeName;
double battalionSize;
int profession; // 0 if no hero
};
// Result of a complete battle simulation
struct BattleResult {
// Winner's player ID (-1 if draw)
PlayerId winner;
// Total number of rounds played
int totalRounds;
// Total number of commands executed
int totalCommands;
// How the game ended
enum class EndReason {
LAST_PLAYER_STANDING, // One player eliminated all opponent units
CRITICAL_TILES_CONTROLLED, // Attacker controlled critical tiles
MAX_ROUNDS_REACHED, // Defender won by surviving max rounds
DRAW // Game ended in a draw
};
EndReason endReason;
// Human-readable description of the result
std::string description;
// Surviving units (for the winner, or all units if draw)
std::vector<SurvivingUnit> survivingUnits;
};
class AiBattleSimulator {
public:
/**
* Initialize the simulator with a battle configuration.
*
* @param config The battle configuration
* @param gameSettings The game settings to use (if nullptr, will initialize default)
*/
explicit AiBattleSimulator(
const BattleConfigProto& config,
GameSettingsSPtr gameSettings = nullptr);
/**
* Run the battle from start to completion.
* This includes:
* 1. Setup phase (placing units)
* 2. Battle phase (combat until victory/defeat/draw)
*
* @return BattleResult containing winner and statistics
*/
[[nodiscard]] BattleResult RunBattle();
private:
// Configuration
const BattleConfigProto config_;
GameSettingsSPtr gameSettings_;
// Game state builder and helpers
[[nodiscard]] GameStateW CreateInitialGameState() const;
[[nodiscard]] std::unique_ptr<ShardokAIClient> CreateAIClient(
PlayerId playerId,
const HexMap* hexMap) const;
// Game loop helpers
[[nodiscard]] BattleResult
RunSetupPhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
[[nodiscard]] BattleResult
RunBattlePhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
[[nodiscard]] bool IsGameOver(const GameStateW& state) const;
[[nodiscard]] BattleResult
CreateResultFromGameState(const GameStateW& state, int totalRounds, int totalCommands) const;
};
} // namespace ai_battle_simulator
} // namespace shardok
#endif // EAGLE0_AI_BATTLE_SIMULATOR_HPP
@@ -0,0 +1,53 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "ai_battle_config",
srcs = ["AiBattleConfig.cpp"],
hdrs = ["AiBattleConfig.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
deps = [
"//src/main/protobuf/net/eagle0/shardok/ai_battle_simulator:ai_battle_config_cc_proto",
"@com_google_protobuf//:protobuf",
],
)
cc_library(
name = "ai_battle_simulator",
srcs = ["AiBattleSimulator.cpp"],
hdrs = ["AiBattleSimulator.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
deps = [
":ai_battle_config",
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//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/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",
"//src/main/cpp/net/eagle0/shardok/util:map_loader",
"//src/main/flatbuffer/net/eagle0/shardok/storage:unit_cc_fbs",
"//src/main/protobuf/net/eagle0/shardok/ai_battle_simulator:ai_battle_config_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:player_info_cc_proto",
],
)
cc_binary(
name = "ai_battle_simulator_main",
srcs = ["ai_battle_simulator_main.cpp"],
copts = COPTS,
data = [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
"//src/main/resources/net/eagle0/shardok/maps",
],
deps = [
":ai_battle_config",
":ai_battle_simulator",
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:fixed_action_point_distances",
],
)
@@ -0,0 +1,163 @@
//
// AI Battle Simulator - CLI Entry Point
//
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include "AiBattleConfig.hpp"
#include "AiBattleSimulator.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
using shardok::ai_battle_simulator::AiBattleConfigLoader;
using shardok::ai_battle_simulator::AiBattleSimulator;
using shardok::ai_battle_simulator::BattleResult;
namespace {
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 << " --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"
<< "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";
}
void GenerateConfigFile(const std::string& outputPath) {
auto config = AiBattleConfigLoader::CreateDefaultPerfConfig();
std::string jsonString = AiBattleConfigLoader::ToJsonString(*config);
if (outputPath.empty() || outputPath == "-") {
// Output to stdout
std::cout << jsonString << "\n";
} else {
// Output to file
std::ofstream outFile(outputPath);
if (!outFile.is_open()) {
std::cerr << "Error: Failed to open output file: " << outputPath << "\n";
std::exit(1);
}
outFile << jsonString << "\n";
std::cout << "Sample config written to: " << outputPath << "\n";
}
}
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);
}
// Create simulator
AiBattleSimulator simulator(*config);
// Run battle
auto result = simulator.RunBattle();
// Print results
std::cout << "\n";
std::cout << "=================================\n";
std::cout << "Battle Complete\n";
std::cout << "=================================\n";
std::cout << "Winner: ";
if (result.winner == 0) {
std::cout << "Attacker (Player 0)\n";
} else if (result.winner == 1) {
std::cout << "Defender (Player 1)\n";
} else {
std::cout << "Draw\n";
}
std::cout << "Result: " << result.description << "\n";
std::cout << "Total Rounds: " << result.totalRounds << "\n";
std::cout << "Total Commands: " << result.totalCommands << "\n";
// Print surviving units
if (!result.survivingUnits.empty()) {
std::cout << "\nSurviving Units (" << result.survivingUnits.size() << "):\n";
for (const auto& unit : result.survivingUnits) {
std::cout << " Unit " << unit.unitId << ": " << unit.battalionTypeName << " ("
<< static_cast<int>(unit.battalionSize) << " troops)";
if (unit.profession > 0) { std::cout << " - Profession " << unit.profession; }
std::cout << "\n";
}
}
std::cout << "=================================\n";
}
} // namespace
int main(int argc, char* argv[]) {
// Set exec path for FilesystemUtils
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
shardok::FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
if (argc < 2) {
PrintUsage(argv[0]);
return 1;
}
std::string configPath;
std::string outputPath;
bool generateConfig = false;
// Parse command line arguments
for (int i = 1; i < argc; ++i) {
std::string arg(argv[i]);
if (arg == "--help" || arg == "-h") {
PrintUsage(argv[0]);
return 0;
} else if (arg == "--generate-config") {
generateConfig = true;
} else if (arg.starts_with("--config=")) {
configPath = arg.substr(9);
} else if (arg.starts_with("--output=")) {
outputPath = arg.substr(9);
} else {
std::cerr << "Error: Unknown argument: " << arg << "\n";
PrintUsage(argv[0]);
return 1;
}
}
// Execute appropriate action
if (generateConfig) {
GenerateConfigFile(outputPath);
} else if (!configPath.empty()) {
RunBattleFromConfig(configPath);
} else {
std::cerr << "Error: Either --generate-config or --config must be specified\n";
PrintUsage(argv[0]);
return 1;
}
} catch (const std::exception& e) {
std::cerr << "Fatal error: " << e.what() << "\n";
return 1;
}
return 0;
}
@@ -0,0 +1,76 @@
{
"map_name": "Alah",
"month": 4,
"max_rounds": 40,
"random_seed": 0,
"attacker": {
"ai_algorithm": "ITERATIVE_DEEPENING",
"units": [
{
"profession": 1,
"battalion_type_id": 4,
"starting_position_index": 0
},
{
"profession": 2,
"battalion_type_id": 4,
"starting_position_index": 0
},
{
"profession": 3,
"battalion_type_id": 4,
"starting_position_index": 0
},
{
"profession": 4,
"battalion_type_id": 4,
"starting_position_index": 0
},
{
"profession": 5,
"battalion_type_id": 4,
"starting_position_index": 0
},
{
"profession": 6,
"battalion_type_id": 4,
"starting_position_index": 0
}
]
},
"defender": {
"ai_algorithm": "ITERATIVE_DEEPENING",
"units": [
{
"profession": 1,
"battalion_type_id": 4,
"starting_position_index": -1
},
{
"profession": 2,
"battalion_type_id": 4,
"starting_position_index": -1
},
{
"profession": 3,
"battalion_type_id": 4,
"starting_position_index": -1
},
{
"profession": 4,
"battalion_type_id": 4,
"starting_position_index": -1
},
{
"profession": 5,
"battalion_type_id": 4,
"starting_position_index": -1
},
{
"profession": 6,
"battalion_type_id": 4,
"starting_position_index": -1
}
]
}
}
@@ -11,6 +11,7 @@
#include "PerformanceTestGameStateBuilder.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
@@ -119,12 +120,22 @@ int main(int argc, char* argv[]) {
const auto* hexMap = currentState->hex_map();
const auto settingsGetter = settings->GetGetter();
ShardokAIClient aiClient(aiPlayerId, isDefender, hexMap, settingsGetter);
ShardokAIClient aiClient(
aiPlayerId,
isDefender,
hexMap,
settingsGetter,
AIAlgorithmType::MCTS);
// Create a second AI client for the human player during setup
// This ensures consistent state handling during setup phase
const PlayerId humanPlayerId = 1;
ShardokAIClient humanSetupAI(humanPlayerId, !isDefender, hexMap, settingsGetter);
ShardokAIClient humanSetupAI(
humanPlayerId,
!isDefender,
hexMap,
settingsGetter,
AIAlgorithmType::MCTS);
// Complete setup phase - AI makes intelligent placement decisions
if (currentState->status()->state() ==
@@ -19,10 +19,10 @@ cc_binary(
"//src/main/cpp/net/eagle0/shardok/ai:ai_attacker_strategy_selector",
"//src/main/cpp/net/eagle0/shardok/ai:ai_defender_strategy_selector",
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening",
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_time_budget",
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//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/settings:game_settings",
@@ -2,7 +2,9 @@
## Overview
This document outlines the implementation plan for an automated AI performance testing tool for Shardok. The tool will replicate the manual performance testing currently done through the Unity client's "Custom Battle" interface, providing reproducible and automated performance measurements.
This document outlines the implementation plan for an automated AI performance testing tool for Shardok. The tool will
replicate the manual performance testing currently done through the Unity client's "Custom Battle" interface, providing
reproducible and automated performance measurements.
## Goals
@@ -52,7 +54,7 @@ struct PerformanceTestResults {
The default configuration replicates the Unity client's "Perf" button:
- **Map**: "Alah"
- **Map**: "Alah"
- **AI Player**: 6 units with professions 1-6, all battalion type 4 (Heavy Infantry)
- **Human Player**: 6 units (no specific configuration needed since AI will control)
- **Defender Toggle**: Configurable (affects starting positions)
@@ -60,12 +62,14 @@ The default configuration replicates the Unity client's "Perf" button:
### 3. Key Components
#### AIPerformanceRunner.cpp
- Main entry point with command-line argument parsing
- Test execution loop
- Results formatting and output
- Integration with ShardokEngine and IterativeDeepeningAI
#### PerformanceTestGameStateBuilder.cpp
- Game state creation utilities (migrated from test code)
- Map loading helpers
- Unit placement logic
@@ -91,7 +95,7 @@ cc_binary(
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening",
"//src/main/cpp/net/eagle0/shardok/ai:ai_attacker_strategy_selector",
"//src/main/cpp/net/eagle0/shardok/ai:ai_defender_strategy_selector",
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/ai:ai_time_budget",
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/shardok/library:engine",
@@ -169,21 +173,25 @@ Overall Results:
### 7. Implementation Phases
#### Phase 1: Basic Infrastructure
1. Create directory structure and BUILD.bazel
2. Implement PerformanceTestGameStateBuilder with minimal game state creation
3. Create basic AIPerformanceRunner that can load a map and create players
#### Phase 2: AI Integration
1. Integrate IterativeDeepeningAI
2. Implement performance metric collection
3. Add basic output formatting
#### Phase 3: Full Feature Set
1. Add command-line argument parsing
2. Implement multiple test configurations (Perf, Rivers, Custom)
3. Add detailed performance metrics and analysis
#### Phase 4: Polish and Documentation
1. Create comprehensive README.md
2. Add error handling and validation
3. Implement baseline comparison features
@@ -68,13 +68,24 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
const unique_ptr<ShardokEngine> &e) {
vector<shared_ptr<ShardokAIClient>> aic;
mcts::MCTSConfig mctsConfig;
mctsConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::AVERAGING;
mctsConfig.maxPlayerFlips = 0;
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
for (const auto &pi : e->GetPlayerInfos()) {
if (pi.is_ai()) {
auto newClient = std::make_shared<ShardokAIClient>(
pi.player_id(),
pi.is_defender(),
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter());
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::MCTS,
ScoringCalculatorType::MCTS_OPTIMIZED,
mctsConfig);
// MCTS config is dynamically adjusted in ShardokAIClient based on proximity:
// - Far from enemy: AVERAGING with maxPlayerFlips=0 (natural wandering prevention)
// - Close to enemy: MINIMAX with maxPlayerFlips=1 (adversarial lookahead)
aic.push_back(newClient);
}
@@ -150,7 +150,10 @@ cc_library(
srcs = [],
hdrs = ["ShardokCommand.hpp"],
copts = COPTS,
visibility = ["//src/main/cpp/net/eagle0/shardok/library:__subpackages__"],
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
":action_cost",
":shardok_action",
@@ -58,6 +58,22 @@ ShardokEngine::ShardokEngine(
startingHistoryState(gsBuffer),
criticalTileCoords(GetCriticalTileLocations(GetCurrentGameState()->hex_map())) {}
ShardokEngine::ShardokEngine(
const GameSettingsSPtr &settings,
const GameStateW &gsBuffer,
const CoordsSet &criticalTileCoords,
const int32_t startingHistoryCount,
const bool trackHistory)
: gameSettings(settings),
settingsGetter(settings->GetGetter()),
availableCommandsFactory(
AvailableCommandsFactory::MakeAvailableCommandsFactory(settingsGetter)),
gameState(gsBuffer),
trackHistory(trackHistory),
startingHistoryCount(startingHistoryCount),
startingHistoryState(gsBuffer),
criticalTileCoords(criticalTileCoords) {}
ShardokEngine::ShardokEngine(
const GameSettingsSPtr &settings,
vector<Unit> &&unplacedUnorderedUnits,
@@ -88,6 +88,14 @@ public:
int32_t startingHistoryCount = 0,
bool trackHistory = true);
// Constructor using existing game state with pre-computed critical tile coords
ShardokEngine(
const GameSettingsSPtr &settings,
const GameStateW &gsBuffer,
const CoordsSet &criticalTileCoords,
int32_t startingHistoryCount = 0,
bool trackHistory = true);
// Constructor for a brand new game
ShardokEngine(
const GameSettingsSPtr &settings,
@@ -135,27 +135,27 @@ auto ActionPointDistancesCache::GetRaw(
MakeCacheKey(mapId, battalionType, includeBravingWater, braveWaterActionPointCost);
// Check the persistent map first
if (persistentCache.contains(cacheKey)) {
if (auto it = persistentCache.find(cacheKey); it != persistentCache.end()) {
#if CACHE_STATS_LOGGING_
cacheStats.persistentHits++;
MaybePrintCacheStats();
#endif
// Return directly from persistent cache without TLS insertion
// This avoids the overhead of thread-local storage operations on hot path
return persistentCache.at(cacheKey).rawPtr;
return it->second.sharedPtr.get();
}
#if CACHE_STATS_LOGGING_
cacheStats.persistentMisses++;
#endif
// Check thread-local cache first (no locks needed!)
if (tlsCache.contains(cacheKey)) {
// Check thread-local cache (no locks needed!)
if (auto it = tlsCache.find(cacheKey); it != tlsCache.end()) {
#if CACHE_STATS_LOGGING_
cacheStats.localHits++;
MaybePrintCacheStats();
#endif
return tlsCache.at(cacheKey).rawPtr; // Raw pointer - zero overhead access!
return it->second.sharedPtr.get(); // Compute raw pointer on demand
}
#if CACHE_STATS_LOGGING_
@@ -224,17 +224,10 @@ auto ActionPointDistancesCache::GetRaw(
// Store both shared_ptr and raw pointer for hybrid access
tlsCache.emplace(cacheKey, CacheEntry(result));
// Prevent unbounded cache growth - limit to reasonable size
if (tlsCache.size() > 100) {
// Simple eviction: clear half the cache when it gets too large
#if CACHE_STATS_LOGGING_
cacheStats.evictionEvents++;
#endif
auto it = tlsCache.begin();
std::advance(it, tlsCache.size() / 2);
tlsCache.erase(tlsCache.begin(), it);
}
// Note: No eviction logic needed here - ConsolidateThreadLocalCache_Racy()
// is called after each AI decision to clear the cache and prevent unbounded growth.
// Previous eviction logic was unsafe as it could free cache entries while raw
// pointers to those entries were still in use, causing use-after-free crashes.
return result.get();
}
@@ -59,11 +59,8 @@ class ActionPointDistancesCache {
private:
struct CacheEntry {
shared_ptr<ActionPointDistances> sharedPtr;
const ActionPointDistances* rawPtr;
explicit CacheEntry(shared_ptr<ActionPointDistances> ptr)
: sharedPtr(std::move(ptr)),
rawPtr(sharedPtr.get()) {}
explicit CacheEntry(shared_ptr<ActionPointDistances> ptr) : sharedPtr(std::move(ptr)) {}
};
// Tier 1: persistent map. This is NOT safe to write to while reads may be happening.

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