Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.7 d12c81bc3b Generate ally-aware Shardok views for non-participant watchers
When a faction watched an ally's battle, the Eagle layer fell back to
Shardok's generic observer (factionId == -1) view because Shardok only
emitted filtered views for battle participants. The observer view
treats every unit as an enemy, so visibility was knowledge-gated:
battalion stats (threshold 50) usually crept above the floor while
hero stats (threshold 75) lagged behind, leaving watchers with
battalion data but no hero stats for their own allies' heroes.

Eagle now sends the set of watcher faction IDs in NewGameRequest, and
Shardok emits one filtered view per watcher in GetUpdates. Each
watcher's view treats their allied participants' units as fully
visible (hero stats, hidden actions like meteor casts, hidden-target
coords) while non-allied units fall back to observer-style knowledge
gating.

The watcher path is implemented via new
GameStateFilteredForPlayerWithAllies and
ActionResultFilteredForPlayerWithAllies overloads that take
alliedPids explicitly, plus a new ShardokEngine::FilterNewResultsForWatcher
that drives them. The IsHiddenFromOpponents predicate now treats the
actor's allies as self for visibility purposes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 20:17:31 -07:00
ba160b9669 AI: break alliance with weakest bordering ally when boxed in (#6700)
When the midgame AI has no neutral or hostile neighbors anywhere on its
border, it has no path to growth and gets stuck in the
chosenNoNeutralNeighborsCommand fallback resting and improving forever.
The upstream allianceOfferAcceptanceChance guard refuses alliances that
would immediately box us in, but that state can still emerge later --
e.g., a shared hostile neighbor gets conquered by our ally.

Add a chooser that, in that state, finds the weakest bordering ally
(measured by province count, since post-truce conquest is the goal)
and emits a DiplomacyAvailable BreakAlliance command targeting them.
Inserted just before chosenNoNeutralNeighborsCommand so the recovery
path runs ahead of the catch-all rest/improve.

Truces are intentionally not treated as boxing-in: they expire, and
breaking would burn trust unnecessarily -- waiting them out is cheaper.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:51:38 -07:00
5664f15dac Push locally-resolved hero names to connected clients (#6698)
Hero names from the local heroNameCache (and FixedHeroName text) are
resolved synchronously inside UnrequestedTextHandler and stored as
complete in the ClientTextStore -- but unlike LLM-generated text, they
never flow through the streaming pipeline that pushes updates to
connected clients. Mid-session UIs sit on the "Hero" fallback until the
user reconnects (which triggers the on-subscribe bulk-send of every
accessible complete text).

Surface the resolved CompleteClientTexts from
clientTextStoreWithHandledUnrequestedTexts via a new
HandledUnrequestedTextsResult, and have GamesManager push them through
GameController.withResolvedTextsPushedToClients (which routes through
the existing humanClientsAfterUpdatingLlmStream path used for LLM
streaming responses). Initial-load callers continue to ignore the
list since the on-subscribe bulk-send already covers that case.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:24:06 -07:00
8009ed62d2 Auto-fire End Turn after AI action when next player has no options (#6699)
The AI thread's post path didn't run the only-one-option auto-loop, so
when an AI player ended their turn and control passed to a player whose
only available command was End Turn (e.g., a player with one stunned
unit), that player had to click End Turn manually. The human-driven
PostCommand path already called PostWhileCurrentPlayerHasOnlyOneOption.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:21:26 -07:00
8abd355883 Suppress FMOD output-switch error in ErrorHandler (#6696)
Unity's internal FMOD wrapper logs an Error whenever the macOS audio
output device changes (headphones plug/unplug, AirPods, system output
switch): the wrapper tries to call setOutput on FMOD after System::init
and FMOD rejects with "Cannot call this command after System::init"
(error 32). The audio continues to play on whichever device was active
at app launch; nothing actionable for us, but the message was reaching
ErrorHandler and surfacing the in-game error panel.

Filter the specific message in ErrorHandler.HandleLog. Match both
halves of the string so a genuine FMOD failure (different cause) still
surfaces.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:11:22 -07:00
fb8408fd64 Gate Observe battle button on ShardokGameModel readiness (#6697)
ShardokBattles is populated when a NewBattle eagle update arrives, but
the matching ShardokGameModel isn't created until the first
ShardokActionResultResponse for that battle reaches the client (in
EagleGameModel.MakeGameModel via the ReceiveGameUpdate handler). In
that gap, the Observe button on an allied battle was unconditionally
interactable, and clicking it threw KeyNotFoundException out of
EagleGameController.GoToBattle's Model.ShardokGameModels[gameId]
indexer access.

Gate the Observe button on Model.ShardokGameModels.ContainsKey(gameId).
Show "Loading..." until the matching shardok model arrives, then flip
to interactable "Observe". The Self/Fight! path was already implicitly
gated via fightableGameId (derived from RunningShardokGameModels), so
no change needed there.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:44:29 -07:00
9f155a359b Centralize popup-driven province highlight in PopupCanvasController (#6693)
* Centralize popup-driven province highlight in PopupCanvasController

Multiple PopupPanelController subclasses (PleaseRecruitMeCommandSelector,
NotificationPanel, ResolveDiplomacyCommandSelector,
ResolveRansomOfferCommandSelector) all wrote directly to
MapController.OverrideTargetedProvinces from their SetUpPanel hooks,
and SetUpPanel only ran when state changed. So when a notification
arrived or was dismissed while the Please Recruit Me popup was open,
the notification panel's SetUpPanel would overwrite the recruit
popup's province highlight, and the recruit popup would never
re-assert. Result: the requesting hero's province often wasn't
highlighted even though the popup said it should be.

Switch to a single-writer model:

- PopupPanelController exposes CurrentAffectedProvinceIds (the current
  popup's affectedProvinceIds), and no longer writes to MapController
  itself.
- PopupCanvasController gains a MapController reference and, in
  Update, applies the topmost active panel's CurrentAffectedProvinceIds
  every frame, so popups can't clobber one another. On the transition
  from "popup active" to "no popup", we clear the override once and
  then leave the field alone so non-popup writers (table hovers etc.)
  can drive it normally.
- ResolveDiplomacyCommandSelector and ResolveRansomOfferCommandSelector
  used to compute their highlight inline in SetUpPanel; that logic
  moves into the PopupInfo construction so each offer carries the
  right affectedProvinceIds, and their SetUpPanel overrides shrink
  accordingly. PleaseRecruitMeCommandSelector and NotificationPanel
  already populated affectedProvinceIds correctly and didn't need
  changes.

PopupCanvasController has a new public MapController field. Wire it in
the prefab/scene where the PopupCanvasController lives (drag the
MapController object onto the new "Map Controller" Inspector field)
before testing — without it, opening a popup will throw a
NullReferenceException, which is intentional per the project's
no-defensive-checks policy on Inspector fields.

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

* Wire MapController into both PopupCanvasController instances in Gameplay scene

Companion to the previous commit. Two PopupCanvasController instances
exist in Gameplay.unity (narrow + wide layout); both now reference the
MapController so the new single-writer Update path can drive
OverrideTargetedProvinces from any active popup.

Also includes auto-saved DialogueManager unitHighlight default colors
that Unity wrote into the scene when loading after the prior tutorial
unit-highlight PR landed — incidental, but moot to revert.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:28:22 -07:00
19cf9c592a Add failing test for backstory visibility on hero imprisonment (#6695)
* Add failing test for backstory visibility on hero imprisonment

When a hero is captured via battle aftermath and imprisoned, the captor
faction has no visibility on the prisoner's backstory text. This test
asserts the imprisonment ActionResult should emit a
ClientTextVisibilityExtensionC granting the imprisoning faction
visibility on the captured hero's backstory text -- matching the
parallel pattern in ProvinceConqueredAction.afterUpdatingUnaffiliatedHeroes.

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

* Extend backstory visibility to captor in convertToUnaffiliated

When a captured hero with an existing backstory is converted into an
unaffiliated hero (imprisoned or exiled), emit a
ClientTextVisibilityExtensionC granting the captor faction visibility
on the prisoner's backstory text. Without this the captor sees blank
text for their own prisoner -- the same gap ProvinceConqueredAction
already closes for inherited unaffiliated heroes when a province changes
hands.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:27:56 -07:00
537f846fee March marks the acting (menu) province as acted (#6694)
* Add failing test: March should mark the acting (menu) province as acted

Currently, MarchCommand.make takes only originProvinceId and marks
that as the only acted province (via provinceIdActed). If the player
opens the command menu on province A and chooses to march units from
a different origin province B, only B is marked acted. Province A
retains its full command menu and can issue another command in the
same round — the player can effectively shuffle units around without
spending province A's action.

Plumbing changes (necessary for the test to express the desired
behavior, behavior unchanged):

- Add actingProvinceId: ProvinceId parameter to MarchCommand.make.
  The inner MarchCommand class accepts it but currently marks it
  @scala.annotation.unused — immediateExecute does not yet emit a
  hasActed change for the acting province.
- CommandFactory passes ac.actingProvinceId from the MarchAvailable
  pair when constructing the MarchCommand. The wire protocol already
  carries the menu province on EagleCommand.province_id, so no proto
  or client changes are required.
- Existing MarchCommandTest cases pass actingProvinceId =
  originProvinceId to preserve current behavior; they all still pass.

New failing test:

- "should mark the acting (menu) province as having acted, even when
  marching from a different province" — sets actingProvinceId=14,
  originProvinceId=7, asserts the result includes a ChangedProvinceC
  for province 14 with setHasActed = Some(true). Currently fails:
  "None was not equal to Some(true)" — no ChangedProvinceC for 14
  exists in the result.

Implementation fix to follow.

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

* Mark the acting (menu) province as acted on March

Implementation for the test added in the previous commit.

When actingProvinceId == originProvinceId (the typical case where the
player issued March from the same province their units march from),
provinceIdActed = Some(originProvinceId) already marks that single
province as acted. No extra change needed.

When actingProvinceId != originProvinceId (the player opened the
menu on A, chose origin B), append a ChangedProvinceC for the acting
province with setHasActed = Some(true) so it can no longer issue
commands this round. The origin still gets hasActed via
provinceIdActed.

Drops @scala.annotation.unused on actingProvinceId since it's now
used.

All 8 MarchCommandTest cases pass, full Scala suite (223 tests) green.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:19:12 -07:00
2bd9fb4022 Compute PersistedActionResult.gameState lazily (#6692)
formAwrs (called on game load) and withNewResults (called per applied
result at runtime) were eagerly running GameStateConverter.toProto on
every Scala game state to populate PersistedActionResult.gameState —
3 seconds of the 4.5-second game-load time per the timing logs and
ongoing per-result cost during play.

In practice, the proto game-state is only ever read at "last entry of
batch" boundaries:

- PersistedHistory.scala:214 — last entry of a loaded chunk during
  orphan recovery.
- PersistedHistory.scala:471 — invariant check on
  recentHistory.headOption.gameState.actionResultCount.
- PersistedHistory.scala:592 — results.last.gameState in saveNow,
  passed to the next batch as starting state.
- PersistedHistory.scala:664 — toSave.last.gameState in withNewResults
  chunk save, passed to next batch as starting state.

So at most ~1 in resultsPerSaveFile (25) entries ever needs the proto
form; for the others the conversion was pure waste. Make
PersistedActionResult.gameState a lazy val derived from
scalaGameState — entries that never become a "last in batch" never
pay for toProto. (Also drops the unused fromProto factory.)

formAwrsFromScala still eagerly converts the action result to proto
since saveIndividualResult writes individual ActionResult bytes for
crash recovery — that conversion happens regardless.

223 Scala tests pass.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:03:18 -07:00
21eb10d2e5 Skip proto conversion in PersistedHistory.stateAfter replay (#6691)
The disk-load branch of stateAfter went through formAwrs, which
unconditionally calls GameStateConverter.toProto on every replayed
intermediate state — wasted work for stateAfter callers, since the
replayed entries are transient and never persisted. With a 19k-action
game, a single stateAfter(14790) call (e.g. from
UnrequestedTextHandler processing a stale incomplete LLM request)
materialized ~4500 PersistedActionResult instances, each holding both
proto and Scala forms of the game state, allocating an estimated
1.1-1.5 GB and OOMing the 2g heap on Province.apply during one of
those toProto calls.

Add a private replayScalaOnlyToState helper that replays in Scala and
returns just the final state, with no proto conversion. Use it in the
stateAfter disk-load branch, and short-circuit the replay at the
target index by taking only `count - initialIndex` actions instead of
replaying through persistedCount.

Behavior preserved:

- formAwrs is unchanged (the save flow still gets eager proto for
  in-memory entries, avoiding repeated toProto cost across save
  accumulations).
- partialGames load range is unchanged (chunk-range optimization
  intentionally deferred).
- recentHistory branch is unchanged.

Per-call impact on the failing stateAfter(14790) path:

  before: replay 4575 actions, run toProto 4575 times,
          retain Vector[PersistedActionResult] of length 4575 with
          proto + Scala state on every entry — ~1.1-1.5 GB transient.
  after:  replay 15 actions, no toProto calls,
          allocate one GameState — a few MB.

223 Scala tests pass.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:39:27 -07:00
1d99eefbd6 Fix CombatUnitSelector hero/battalion matching (#6688)
* Add failing tests for CombatUnitSelector hero/battalion suitability

Documents three desired behaviors that the current greedy
selectedCombatBattalions() does not satisfy:

1. Engineer should be taken over a no-profession hero on a Longbowmen
   battalion when only one slot is available — Suboptimal pairing is
   preferable to leaving the much more capable Engineer behind.
2. Same for a Mage: a Suboptimal Longbowmen pairing is preferable to
   leaving the Mage behind.
3. A Mage with only Restrictive options (e.g. only a non-casting
   Heavy Cavalry battalion) should be skipped entirely, not glued to
   the Restrictive battalion via the getOrElse fallback.

The current code:

- Uses a lexicographic (suitability.ordinal, -power) sort, so any
  Optimal hero beats any Suboptimal hero regardless of how much more
  capable the Suboptimal hero is. This causes (1) and (2).
- Has a getOrElse fallback that pairs the strongest hero with the
  strongest battalion when every (hero, battalion) pair is Restrictive,
  ignoring suitability. This causes (3).

These tests fail today and will pass after the planned fix.

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

* Fix CombatUnitSelector matching: soft Suboptimal penalty, skip Restrictive

Three changes to selectedCombatBattalions's chooseOne:

1. Replace the lexicographic (suitability.ordinal, -power) sort with a
   soft scoring scheme: power minus SuboptimalPairingPenalty (50) when
   the pairing is Suboptimal. An Engineer's +200 profession bonus still
   outweighs a no-profession hero on a Suboptimal Longbowmen pairing,
   but an equally-strong Optimal hero still wins the slot.
2. Filter Restrictive heroes out of the candidate pool entirely for the
   chosen battalion rather than relying on suitability ordering — so a
   Mage can never win a non-casting battalion via lexicographic ranking.
3. Replace the getOrElse fallback (which previously glued any hero to
   any battalion regardless of suitability) with a three-way match:
   valid pairing exists -> take it; no battalions left -> strongest
   hero marches alone (preserves prior behavior); battalions remain but
   every pairing is Restrictive -> return None and stop adding heroes.

The five tests in CombatUnitSelectorTest now all pass.

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

* Take Restrictive pairings rather than abandon a battalion

Earlier in this branch we treated "all options for a hero are
Restrictive" as a signal to skip the hero. That was wrong: a Restrictive
pairing (e.g. Mage leading Heavy Cavalry) is still better than leaving
the battalion behind entirely. The correct rule is "prefer non-Restrictive
heroes when any are available, but fall back to Restrictive rather than
losing the unit."

- Replace test 3 ("leave a mage behind ... if every battalion is
  Restrictive") with the inverse: "take a mage on a Restrictive
  battalion if that's the only way to take the battalion at all".
- Add a counterpart test confirming a no-profession hero is still
  preferred over a mage on a Restrictive Heavy Cavalry when both are
  available.
- Simplify chooseOne: always pick the strongest battalion, prefer
  non-Restrictive heroes for it, fall back to Restrictive heroes if
  none are non-Restrictive. Drop the early-return / skip path.

All six CombatUnitSelector tests pass; full Scala suite (223 tests)
still green.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 07:16:41 -07:00
ebc29a75d4 Default Shardok server status to WAITING_FOR_AI when no faction is queued (#6689)
In Eagle battles, computeShardokServerStatus checks each faction's
currentCommand to decide between YOUR_TURN, WAITING_FOR_AI,
WAITING_FOR_HUMAN_PLAYER, and PROCESSING. The AI's currentCommand,
however, isn't populated during the window when the AI is computing
its move — so the typical post-player-turn state had no faction with
a current command and fell through to PROCESSING. Clients never
observed WAITING_FOR_AI in practice; they bounced between YOUR_TURN
and "Processing...".

When no faction has a current command, infer the most useful status
from which other factions are present in the battle: WAITING_FOR_AI
if any AI faction remains, WAITING_FOR_HUMAN_PLAYER if any other
human remains, and PROCESSING only when no other factions are present
(an edge case at battle boundaries).

The custom-battle path (CustomBattleManager.customBattleStatus) was
already binary YOUR_TURN/WAITING_FOR_AI and not affected.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 07:05:47 -07:00
f0d7067cc9 Wire ShardokServerStatus into the connection-status indicator (#6686)
* Wire ShardokServerStatus into the connection-status indicator

Client half of the Shardok-side connection-status work. The server
already populates ShardokServerStatus on SingleShardokGameResultResponse
(see #6685); this hooks it up to the persistent ConnectionStatusUI so
players can see whether they're up, the AI is thinking, or another human
is about to act while a battle is on screen.

- ShardokGameModel implements IShardokGameStateProvider and caches the
  latest ShardokServerStatus, updated as new responses arrive in
  EagleGameModel.
- ConnectionStatusUI gains a Shardok provider slot; when set, it renders
  Your Turn / Waiting for AI / Waiting for Other Player / Processing
  instead of the strategic-layer status.
- EagleGameController binds the UI to the Shardok model when entering a
  battle; ShardokGameController clears the binding on exit.

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

* Fix PersistentUIManager namespace in ShardokGameController

CI build failed with:
  error CS0234: The type or namespace name 'PersistentUIManager' does
  not exist in the namespace 'eagle'

PersistentUIManager lives in the `common` namespace, and Shardok already
has `using common;`, so the `eagle.` qualifier was both wrong and
unnecessary.

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

* Wire ShardokServerStatus through CustomBattleHandler

Custom battles use CustomBattleHandler as the gRPC subscriber instead
of GameModelUpdater, so the existing wiring on EagleGameController +
EagleGameModel didn't reach them — the indicator stayed on "Connected"
in custom battles even after the server began populating
ShardokServerStatus correctly.

- Set the ShardokGameModel as the connection-status UI's Shardok game
  state provider when entering a custom battle. Cleanup is already
  handled by ShardokGameController.EndTurn for both battle paths.
- Forward resp.ShardokServerStatus to the model from
  CustomBattleHandler.ReceiveGameUpdate, mirroring the per-response
  handling that GameModelUpdater already does for Eagle battles.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 06:58:32 -07:00
e05ea92c46 Populate ShardokServerStatus in remaining response paths (#6687)
* Add doc describing server-side work for Shardok status indicator

Hand-off note for the Scala worktree: client wiring on
shardok-status-client is complete, but the indicator only ever shows
"Connected" in battle because the server attaches a ShardokServerStatus
proto with status left at the default UNKNOWN value.

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

* Populate ShardokServerStatus in remaining response paths

PR #6685 added the ShardokServerStatus proto and populated it in
GameController.humanClientsAfterPostingResults, but three other
SingleShardokGameResultResponse construction sites left the field
unset, defaulting to UNKNOWN on the wire and producing the symptom
flagged in docs/SHARDOK_SERVER_STATUS_SERVER_WORK.md.

Fix:

- HumanPlayerClientConnectionState.streamUpdates / apply factory now
  take a shardokServerStatusFor: ShardokGameId => ShardokServerStatus
  callback and set the field on each per-battle response. Avoids a
  Bazel dep cycle into game_controller by accepting the precomputed
  function rather than calling computeShardokServerStatus directly.
- GameController.streamUpdates wires
  GameController.computeShardokServerStatus into that callback.
- CustomBattleManager populates the field at both construction sites
  via a small helper (YOUR_TURN if the human has a current command,
  WAITING_FOR_AI otherwise) — custom battles have only one human
  faction and the AI on -1, so the full computeShardokServerStatus
  machinery is overkill.

Existing test sites in HumanPlayerClientConnectionStateTest pass a
stub shardokServerStatusFor since they don't exercise active battles.

Removes the now-stale TODO doc — its diagnosis (enum never set) no
longer applies.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 15:49:01 -07:00
b0639dc8d0 Add ShardokServerStatus proto and server-side population (#6685)
Introduces a new ShardokServerStatus message on
SingleShardokGameResultResponse so the client can render a tactical-side
connection-status indicator (Your Turn / Waiting for AI / Waiting for
Other Player / Processing).

ShardokServerStatus is intentionally separate from ServerGameStatus so
the strategic and tactical layers don't have to evolve in lockstep.

GameController.computeShardokServerStatus picks the status from the
receiving player's perspective by checking shardokPlayerAvailableCommands
across factions and classifying them via aiClientFactionIds. The two
humanClientsAfterPostingResults overloads now thread an aiFactionIds set
through and populate the new field on every per-battle response.

This is the server half of a two-PR split. The client wiring lands
separately; until then the new field is emitted but unread, which is
safe.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 06:07:54 -07:00
9eb8f7f086 Highlight March button in post-taxes tutorial dialogue (#6683)
Matches the existing pattern used for the Improve Province and Give Alms
buttons earlier in the strategic tutorial.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:49:53 -07:00
9f18322013 Increase Handle Captured Heroes panel font size (#6682)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:36:31 -07:00
7306c9cb06 Don't block dropGame on archive + battle cancellation (#6681)
When the last human drops a running game, the previous order was:
cancel-battles → archiveGame → mutate state → save() → return. Both
cancelBattlesForGame (gRPC roundtrip) and archiveGame (per-S3-file
copy + delete, all serialized) ran inside this.synchronized AND inside
the lock that gates the lobby push, so users could wait several
seconds to see the dropped game disappear from the lobby list.

Now we mutate state and save() first so the lobby update reflects the
removal immediately, then run cancelBattles + archive on a background
Future. Failures are logged.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:28:12 -07:00
3ff012806f Reliably highlight reinforcement heroes in battle tutorial (#6679)
* Highlight Elena (and other reinforcements) reliably in tutorial dialogues

Elena Fyar's hex wasn't pulsing during her arrival dialogue even though
the trigger fired. The most likely causes are: she's matched in
ReserveUnitsById rather than UnitsById at the moment the dialogue plays,
the SelfHostility check rejects an allied-rendered reinforcement, or her
ProfessionInfo isn't populated on whichever diff happens to be the
latest UnitView in UnitsById when ApplyUnitHighlight runs.

Three changes, defense in depth:

- DialogueManager.ApplyUnitHighlight now scans UnitsById ∪ ReserveUnitsById
  (still requires a placed Location), and treats any non-enemy unit as
  highlightable rather than requiring SelfHostility.
- Elena's and Ranil's reinforcement dialogues, plus engineer_bombard
  (which is Ranil speaking about himself), now match by heroName instead
  of profession. Each dialogue is about a specific hero by name, and
  heroName goes through unit.Name (text-id-based) which doesn't depend
  on ProfessionInfo being present on the latest diff.
- duel keeps profession matching since the dialogue is about Champions
  in general.

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

* Re-apply tutorial unit highlight every frame

The Shardok controller's ModelUpdated() calls hexGrid.ClearOverlays() on
every model update, which wiped the tutorial unit highlight applied
during step 1 of a multi-step dialogue: by the time the player was
reading Marek's lead-in line about Elena (or Ranil), a follow-up server
update had cleared the overlay. Step 2 happened to survive because no
further model updates arrived between the player advancing the dialogue
and the render.

DialogueManager now stores the current step's highlight criteria and
re-applies it every frame from Update() so it survives any number of
ClearOverlays calls. ApplyUnitHighlight is now idempotent (clears its
tracked cells before re-scanning).

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:15:39 -07:00
904dadf170 Bump Luke the Prank-tricker tutorial bias to 300 to outpace decay (#6680)
The previous 130 setting netted +30 effective bias after
FactionBiasFromImprisonment (-100), which then decayed to 0 within
3 rounds via NewRoundAction's per-round bias decay (additive -10/round
below |100|, multiplicative *0.9 above). By the time prison-round odds
had accumulated, the bias contribution was already gone.

Setting the config to 300 yields +200 effective bias, putting it in the
multiplicative-decay regime: ~+131 by round 4 (Nov) and ~+96 by round 7
(Feb). That keeps Luke comfortably over MinOddsForRecruitment (80)
through the intended early-tutorial recruitment window.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:05:36 -07:00
579d04eafd Rename knights in tutorial opening dialogue to Doomriders (#6678)
Old Marek's first tutorial-battle line previously named Shardok's Guard
as "the finest soldiers in the realm" and referred to "knights besides"
as a separate unit. Since the heavy-cavalry battalion in the attacking
army is named Doomriders, attribute the knights line to them directly
and drop the implicit second unit reference.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:46:48 -07:00
acdea65632 Highlight referenced units during battle tutorial dialogues (#6676)
Several battle tutorial dialogues call out specific units by role
(longbows for archery, the champion for dueling, Marek for charging,
the engineer/paladin reinforcements, Hedrick for hiding) or by an
available action (melee, start fire). The dialogue had no way to point
at the unit on the battlefield, so the player had to find it themselves.

Adds an optional `highlightUnit` block to dialogue steps with criteria
fields (battalionType, profession, heroName, canStartFire, canMelee)
that DialogueManager resolves against the current Shardok model. Each
matching friendly unit gets a strobing border via HexGrid.OverlayCell
— the same primitive already used for archery range and movement
overlays — so the visual language matches existing combat highlights.
The overlay is cleared on step change and dialogue end.

Wires up the criteria for all relevant battle dialogues:
- archery_available → Longbowmen
- ability_charge_available → Old Marek the Learned
- melee_available → friendly unit with a MeleeCommand available
- start_fire_available → friendly unit with CanStartFire
- duel_available → Champion
- tutorial_reinforcement_paladin → Paladin (Elena)
- tutorial_reinforcement_engineer → Engineer (Ranil)
- engineer_near_enemy → Engineer
- hide_available → Hedrick the Hedge-Merchant

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:38:18 -07:00
b4bb2f6d7f Bump Luke the Prank-tricker tutorial recruitment bias to 130 (#6677)
The tutorial configures +110 faction-3 bias on Luke so that he becomes
recruitable shortly after capture, but EndBattleAftermathPhaseAction
applies FactionBiasFromImprisonment (-100) to the imprisoning faction
and merges in initialFactionBiases on top of it. The effective bias was
only +10, which left him needing several extra rounds of prison-time
odds adjustment before reaching MinOddsForRecruitment (~Feb/Mar 372 in
practice). Bumping to 130 raises effective bias to +30 (~4 rounds
earlier), pulling first recruitability to ~Nov 371 as intended.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:30:58 -07:00
44546ac3e0 Mutually distrust the three tutorial NPC factions (#6675)
Bregos, Bridget, and Hedrick now all start at -500 trust toward each
other. With trust drift at +1/round, that's effectively permanent for
tutorial timescales. The invitation gate (TrustForDiplomacy.scala) only
checks the inviter's trust toward the target, so previously Bregos's
trust toward the smaller factions was an unset 0 and he could try to
absorb them after his earliestRoundForInvitation passed.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:27:26 -07:00
ab44d53b60 Point tutorial highlight at the Fight button on the battle progress panel (#6674)
The opening tutorial's pre-battle step instructed the player to click
"Battle!" and highlighted "GoToBattleButton", but that UI was replaced
by per-battle Fight buttons on the BattleProgressPanel. The dialogue now
references "Fight!" and BattleProgressRowController dynamically registers
its action button as "FightButton" with the tutorial target registry while
the row is in the fightable state, unregistering otherwise (and on
destroy) to avoid stale highlight targets.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:03:09 -07:00
02cffbdccb Check off completed tutorial items (#6673)
The Shardok tutorial, narrative hook, and early-victory items are
already delivered by the dialogue system: tutorial_battle.json covers
combat mechanics start to finish, tutorial_opening provides Sadar's
backstory in the first scene, and surviving Tarn's attack is the
early-victory beat.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:52:07 -07:00
152b72d691 Load Eagle/Tarn province setup from game_parameters.json (#6671)
EagleAppearsAction had Province32Stats / Province6Stats hardcoded
alongside hardcoded battalion definitions and random hero counts —
all duplicating the corresponding entries in game_parameters.json.
Any tuning of the normal game's Ingia/Soria starting state would
silently diverge in the tutorial.

Replace the hardcoded constants with a lazy load from
GameParametersUtils.defaultExpandedGameParameters: find the
SetFaction whose head is The Eagle, derive ProvinceConfig (stats,
random hero count, battalion templates) from each occupied province,
and look up which province belongs to the Eagle vs. Tarn by primary
ruling hero name. Battalions come through BattalionConverter.fromProto
and have IDs assigned at result time.

Behavior is preserved (verified by EagleAppearsActionTest).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:23:03 -07:00
ea7677e351 Add dialogue for Ranil and Elena forming their own factions (#6672)
* Add dialogue for Ranil and Elena forming their own factions

The tutorial_hero_faction_appears trigger fires when John Ranil and
Elena Fyar break off from Sadar's service to form their own factions
(Builders of a Better World, Champions of Justice) seven rounds after
The Eagle appears. The trigger had no dialogue script, so a major
narrative beat — losing two of the player's signature allies — fired
silently.

Add a 4-step dialogue: Marek frames the departures as the same
mysterious force that has been claiming the King's heroes; Ranil and
Elena each speak their reason for leaving; Marek closes by noting they
are not enemies and an alliance may be possible later.

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

* Sharpen Marek's closing line to convey ambition

Old Marek now frames Ranil and Elena as obstacles the kingdom may need
to go through, not just former allies on different paths.

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

* Don't blame Bregos Fyar as the realm's arsonist

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:15:45 -07:00
d57003e144 Add tests for EagleAppearsAction (#6670)
The TutorialStrategicEventsTest comment claimed EagleAppearsAction's
result-creation was tested in TutorialGameCreationTest, but no such
tests existed — leaving the Eagle/Tarn faction setup uncovered.

Add EagleAppearsActionTest covering: faction creation and hostility,
The Eagle hero, Tarn's faction switch and loyalty, province 32 and 6
ownership / hero placement / stats, and battalion creation. Run
against a real bootstrapped tutorial game state via
TutorialGameCreation.createTutorialGame.

Also fix the stale comment in TutorialStrategicEventsTest.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:10:04 -07:00
4c720983b7 Fix stale tutorial-battle origin province reference (#6669)
Doc said Tarn's attacking army originates from province 32; the
config in tutorial_parameters.json:102 has originProvinceId: 31.
Province 32 (Ingia) is unoccupied at game start and only becomes
relevant later when The Eagle appears.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:58:05 -07:00
5831dc6e7a Tutorial system doc: add missing triggers and full expected flow (#6668)
Catalog four triggers missing from the original doc:
tutorial_faction_appears, tutorial_hero_faction_appears,
tutorial_hero_departed, and tutorial_hero_departed_again (all from
OnStrategicActionResult), plus shardok_battle_reset (fired directly
from ShardokGameController, bypassing the registry).

Replace the brief expected-flow stub with the full strategic-map and
tactical-battle dialogue flow tables, plus the underlying narrative
arc, so future tutorial work has a concrete map of what fires and
when.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:12:42 -07:00
79ee1e4e28 Docs: update Small Eagle TODO and add tutorial system architecture doc (#6667)
* Update Small Eagle TODO: lobby fixes done, merge tutorial + onboarding

Mark lobby fixes complete and consolidate the tutorial and
first-session onboarding bullets into a single section, since they
overlap heavily (a guided first scenario is essentially a tutorial).

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

* Add tutorial system architecture doc

Document the Unity tutorial system's class map, step lifecycle,
trigger catalog (with file:line citations), highlight targets,
persistence, and expected first-session flow. Calls out that the
step-based UI is currently dormant (RegisterAll early-returns) and
the dialogue system is the live path today.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:01:59 -07:00
26100acbe5 Show a proper page with a back link when a game URL is not found (#6666)
Previously /games/<id> returned a plain-text 404 with no way back to
the games list. Now renders an HTML error page through the shared
layout with a link back to /games.

Adds a generic error.html template and a renderError helper so other
handlers can use the same pattern.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:11:09 -07:00
0af9a11bc0 Commit missing .meta files for tracked scripts (#6665)
AddressableLoader.cs and AssetUsageAuditor.cs were committed without
their .meta files, so each dev's Unity was generating a different
local GUID. Commit the metas to lock the GUIDs so any future
prefab/ScriptableObject reference will be stable across clones.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 13:45:17 -07:00
a9ebe48a76 Align battle thermometer rows regardless of troop-count visibility (#6664)
Rows where the viewer sees troop counts (allied) render non-empty
AttackerLabel / DefenderLabel, while other rows render them empty.
The labels' LayoutElement had MinWidth=-1, so Unity fell through to
TMP's content-dependent minWidth, making the bar area's inner edges
land at different X positions across rows whenever available width
was tight.

Pin MinWidth=100 (== PreferredWidth) on both labels so their width is
content-agnostic and the bar areas line up.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 21:19:48 -07:00
a582af560d Update Gameplay scene layout (#6663)
Rebakes many UI RectTransforms in the Gameplay scene: several panels
switch to layout-driven sizing (anchors/position/size zeroed to let a
parent LayoutGroup/ContentSizeFitter drive them) and a few panels get
explicit resizes.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 20:15:15 -07:00
adminandGitHub 28f0081a86 Fix turn-mismatch reconnect during chronicle text generation (#6662)
* Report WAITING_FOR_PLAYERS while an AI is taking its turn

computeServerGameStatus only considered other human factions when
checking whether to report WAITING_FOR_PLAYERS, so when a human player
finished their turn and the server transitioned to AI play it returned
None. The client keeps its previous YOUR_TURN status when the server
sends a None update (ServerStatus is only overwritten when non-null)
while HandleAvailableCommands clears commands on the same update, so
CheckForTurnMismatch fired a spurious reconnect after its 3s grace.

Pass all other faction IDs (human + AI) into computeServerGameStatus
so WAITING_FOR_PLAYERS is reported whenever any other player has
commands to act on.

* Count unrequested (queued) texts in hasIncompleteTextsAccessibleTo

Chronicle texts begin their life in unrequestedTexts and only move to
incompleteTexts once withMarkedRequested fires as the LLM request
actually starts. If computeServerGameStatus ran during that window -
after the player's commands had already cleared but before the LLM
request had been kicked off - hasIncompleteTextsAccessibleTo returned
false, the status fell through to None, and the client kept its stale
YOUR_TURN while its commands were cleared on the same update. That is
the actual trigger for the 3s turn-mismatch reconnect we kept chasing:
waiting for the chronicle text to start streaming, not waiting for an
AI turn.

Include 'unrequested' alongside 'incomplete' in both the in-memory
default implementation and the SQLite EXISTS query so we report
GENERATING_TEXT for the full queued + generating window.
2026-04-22 19:08:41 -07:00
adminandGitHub fe6b50b055 Unify ServerGameStatus reported on re-subscribe with post-result path (#6661)
The re-subscribe path in HumanPlayerClientConnectionState.streamUpdates
was computing ServerGameStatus using simpler logic than
humanClientsAfterPostingResults: it only reported YOUR_TURN (when the
player had commands) or None otherwise. When a client re-subscribed
while another player was taking their turn, a battle was in progress, or
chronicle text was generating, the server sent serverGameStatus=None and
empty availableCommands. The client cleared its command state but kept
the stale YOUR_TURN status from the previous update (ServerStatus is
only overwritten when non-null), which tripped CheckForTurnMismatch
after its 3s grace and triggered a spurious reconnect.

Extract GameController.computeServerGameStatus as a single source of
truth, and route it through HumanPlayerClientConnectionState.apply and
through rewindTo so all three paths agree on
BATTLE_IN_PROGRESS / YOUR_TURN / GENERATING_TEXT / WAITING_FOR_PLAYERS.
2026-04-22 07:26:10 -07:00
4ee0230b18 Add failing test for fleeing army at hostile province (#6660)
* Add failing test: fleeing army at hostile province should not shatter

A defender that fled a battle lands at its pre-selected flee province
with fleeProvinceId cleared. If that province has since been conquered
by the enemy, shatterStrandedArmies destroys the army outright — but the
army still has combat power and should get a chance at battle via the
normal HostileArmySetup → AttackDecision flow.

This commit flips the prior "should shatter" test to the behavior we
actually want. Fix to follow.

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

* Remove shatterStrandedArmies so fleeing armies can trigger battle

Fleeing armies arrive at their pre-selected flee province with
fleeProvinceId cleared (by ResolveBattleAction) so they can't flee
again. If the province has been conquered by the enemy in the meantime,
shatterStrandedArmies was destroying the retreating force outright
during ProvinceMoveResolution — even though the army still had combat
power and should get a chance at battle.

Removing the block lets these incoming armies fall through to
HostileArmySetup later in the same round, where they become hostile
army groups and trigger the normal AttackDecision → battle flow.

Also drops the now-unused ShatteredArmyUtils.shatteredStrandedArmyResult
helper.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 08:20:27 -07:00
af75f7af74 Add UpgradeBattalionQuest AI quest handler (#6659)
Adds a deterministic chooser that walks a priority tree per turn toward
fulfilling an UpgradeBattalionQuest: arm-completes (optionally preceded by
travel and/or a safe food sale to cover the gold cost) -> train-completes
-> march in a pre-qualified neighbor battalion -> organize/top-off of the
quest battalion type -> march in an unqualified neighbor battalion ->
develop Economy/Agriculture -> train progress -> develop Infrastructure
-> arm progress. Travel is only toggled when it directly enables an
arm-completes finisher, to avoid fragile multi-turn plans.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 22:12:37 -07:00
614534ac54 Add BetrayAllyQuest AI quest handler (#6658)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 22:02:24 -07:00
4696d56ec6 Add FightBeastsAloneQuest AI quest handler (#6656)
* Add FightBeastsAloneQuest AI quest handler

When an unaffiliated hero offers a FightBeastsAloneQuest, the AI now
sends the weakest expendable non-leader hero to fight beasts without a
battalion. Only heroes weaker than the quest-giving hero are considered,
since the hero will almost certainly die.

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

* Add MaxExpendableHeroPowerRatio setting for FightBeastsAloneQuest

Hero must have power at most 75% of the quest-giving hero's power to
be considered expendable, ensuring the trade is clearly worthwhile.

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

* Add MaxExpendableHeroPowerRatio to settings.tsv and regenerate loader BUILD

The new setting was added to the settings BUILD.bazel but was missing from the
TSV source file, causing the auto-generated settings_loader to lack the dep.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:47:13 -07:00
4af198fe1a Add BattalionDiversityQuest AI quest handler (#6657)
* Add BattalionDiversityQuest AI quest handler

When an unaffiliated hero offers a BattalionDiversityQuest, the AI now
hires one battalion of a type it doesn't already have. Only types where
the province meets development requirements are considered, and the
province must have sufficient gold and food surplus.

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

* Only hire battalion when it would reach 3+ distinct types

The quest requires 3+ different types at near-full capacity. Skip
hiring if the province has fewer than 2 existing types, since adding
one more wouldn't reach the required 3.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:42:40 -07:00
e74e26f191 Update AI quest completion plan with prioritized backlog (#6655)
Reorganize the "Does Not Attempt" section into a prioritized plan for
future implementation and a list of quests not planned for active
pursuit.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 12:55:25 -07:00
dadb374143 Prefer CrackDown for riots when SuppressRiotByForceQuest exists (#6654)
When an unaffiliated hero has a SuppressRiotByForceQuest and the faction
has battalions available, the AI now prefers CrackDown over Give when
handling riots. This modifies the existing riot handler priority rather
than adding a quest command chooser, since riot handling runs before
FulfillQuestsCommandSelector.

The AI estimates survival odds against a 90th-percentile riot before
committing to CrackDown, and prefers non-leader heroes to avoid risking
the faction head.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 11:22:07 -07:00
5bccde82ac Add ApprehendOutlawQuest AI quest handler (#6653) (#6653)
Matches the quest's target outlawHeroId against available outlaws in the
province and issues an ApprehendOutlaw command when found.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:46:39 -07:00
b0c7c57402 Add ReconSpecificProvincesQuest AI quest handler (#6652) (#6652)
Higher priority than the count-based ReconProvincesQuest since reconning
a specific province also counts toward generic recon counts. Prefers
targets not yet reconned by the faction.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:26:59 -07:00
f6850fc838 Add ReconProvincesQuest AI quest handler (#6651)
* Add ReconProvincesQuest AI quest handler

When an unaffiliated hero has a ReconProvincesQuest, the AI now issues
a Recon command to reconnoiter a province, helping fulfill the quest.

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

* Prioritize recon target provinces in ReconProvincesQuestCommandChooser

Sort candidates by (isNeighbor, isHostile, notRecentlyReconned) to
prefer hostile neighboring provinces that haven't been scouted recently,
matching the prioritization pattern from MidGameAIClient.

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

* Deprioritize unowned provinces in recon target selection

Unowned provinces (no ruling faction) are now sorted last, even if
they're neighbors, since reconning owned hostile provinces is more
strategically valuable.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:15:28 -07:00
2f783f7ab0 Add GrandArmyQuest AI quest handler (#6650)
* Add GrandArmyQuest AI quest handler

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

* Address review: extract helper, use flatMap/inside, verify quest fulfillable

- Extract grandArmyTarget into a private method with pattern matching
- Replace organize match with flatMap chain
- Only return command if total troops after organizing meet the quest
  target (prevents partial fulfillment)
- Replace asInstanceOf with inside() in tests
- Add test for insufficient resources to meet target

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 08:05:45 -07:00
7bdbd7ec8f Add AI quest handlers for blizzard, drought, and epidemic filtering (#6649)
* Add AI quest handlers for blizzard, drought, and epidemic own-province filtering

Create ControlWeatherQuestCommandChooser to handle StartBlizzardQuest and
StartDroughtQuest via the ControlWeather command. Update StartEpidemicQuest
CommandChooser to skip quests targeting the acting faction's own provinces.
Both handlers verify the appropriate command is available before selecting.

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

* Address review: use inside() pattern and extract questTargets method

- Replace asInstanceOf with inside() pattern in ControlWeather and
  StartEpidemic test assertions
- Extract questTargets into a private method in
  ControlWeatherQuestCommandChooser and match directly on its result

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:35:23 -07:00
334a431f14 Add DevelopProvinces and MobilizeProvinces AI quest handlers (#6648)
* Add DevelopProvinces and MobilizeProvinces AI quest handlers

Implement IssueOrders-based quest command choosers so the AI can
fulfill DevelopProvincesQuest and MobilizeProvincesQuest by switching
province orders. Develop prefers non-hostile-neighbor provinces;
Mobilize prefers hostile-neighbor provinces. Mobilize is skipped when
a DevelopProvincesQuest also exists to avoid conflicting orders.

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

* Extract ProvinceOrderQuestHelper to deduplicate order-switching logic

The Develop and Mobilize choosers shared nearly identical logic for
finding the IssueOrders command, counting current orders, partitioning
by hostile neighbors, and building the selection. Extract this into
ProvinceOrderQuestHelper.chooseOrders parameterized by target order
type, hostile-neighbor preference, and reason string.

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

* Remove early returns from province order quest choosers

Use pattern matching, flatMap, and Option.when instead of early
returns for idiomatic Scala style.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 21:27:30 -07:00
4ba9a475da Filter out Fear from AI search when attacker is alone (#6647)
Without a teammate to capitalize on the stun, a solo attacker casting Fear
just ends its turn no closer to killing units or capturing castles. The
state scorer currently rewards the resulting morale drop on the victim,
so minimax/MCTS can pick Fear over useful actions. Prune it in
AICommandFilter with one exception: if the victim is already on a fire
tile, the stun locks them there and the burn damage supplies the
follow-up, so Fear is still worthwhile in that case.

The rule only applies to attackers — defenders legitimately use Fear to
stall, which is their win condition.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:52:30 -07:00
acb409bf3f Update AI quest completion docs to match code (#6646)
The doc was missing 11 quest handlers that were added since it was
written: all prisoner quests, TotalDevelopment, SpendOnFeasts,
SendSupplies, RestProvince, StartEpidemic, and SwearBrotherhood.
Also fixes the priority order (Alliance is last, not first).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:46:47 -07:00
0389357366 Hide [CNTL] modifier hint on iOS and Android (#6645)
There is no Ctrl key on mobile platforms, so the [CNTL] helper text
under the profession attack button is meaningless. Skip it when
running on IPhonePlayer or Android.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:06:58 -07:00
a470bb16da Hide [CNTL] helper for untargeted profession commands like Fortify (#6644)
The profession attack button group (group 5) always showed "[CNTL]"
underneath, but that hint only makes sense for targeted commands like
Reduce where the player can ctrl-click a hex. Fortify has no target,
so showing the modifier key shortcut is misleading. Now the helper
text only appears when the group has a targeted command.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:02:07 -07:00
37ac215b85 Dedupe stale ActionResultViews in EagleGameModel.HandleUpdates (#6635)
* Dedupe stale ActionResultViews by unfiltered result count on main thread

Eagle's HandleUpdates previously applied every ActionResultView it was
handed, regardless of whether those results had already been applied.
The only count-based safety net was the heartbeat SyncMismatch check,
which triggers a reconnect but does not stop an in-flight MainQueue job
from applying stale deltas first. Applying a stale ActionResultView
(e.g. re-executing VASSAL_EXILED) can corrupt client state and throw
"Duplicate unaffiliated hero".

Track a main-thread-only high-water mark _lastAppliedUnfilteredCount and
gate HandleUpdates on it: if a delivery's UnfilteredResultCountAfter is
not ahead of the high-water mark but carries results, drop them with a
[DEDUP] log line. Reset the high-water mark in HandleStartingState so
state resyncs/rewinds rebuild the baseline cleanly.

This is defense-in-depth alongside the mid-stream SUBSCRIBE fix: it
protects the main thread against any source of stale/duplicate
ActionResultView delivery, not just the specific TryPendingCommands race.

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

* Handle mid-batch exceptions in HandleUpdates by forcing a state resync

The previous commit added a dedupe gate but assumed ActionResultView
batches were applied atomically. If HandleNewHistoryEntry throws partway
through a batch, _currentModel is left partially mutated:
_lastAppliedUnfilteredCount never advances (so future batches would
re-apply the 1..k results that did land), while _lastUnfilteredResultCount
on the gRPC thread has already advanced (so the heartbeat sync check
reports "in sync" and never schedules a reconnect). The client
invisibly corrupts and the dedupe gate itself makes the next delivery
worse, not better.

Wrap the apply loop in try/catch. On exception:
- mark the model _modelCorrupted and refuse to apply any further
  GameUpdates in ReceiveGameUpdate until a fresh StartingState arrives
- reset both _lastUnfilteredResultCount (under its lock) and
  _lastAppliedUnfilteredCount to 0 so the upcoming subscribe asks the
  server for a fresh snapshot
- report the exception via ErrorHandler so we still get a stack trace
- trigger PersistentConnection.ForceReconnect() to drive the resync

HandleStartingState clears the _modelCorrupted flag when the fresh
snapshot lands, at which point normal update processing resumes.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:57:26 -07:00
830985e01f Run S3 archive cleanup on self-hosted runner with aws CLI (#6643)
Switches from ubuntu-latest to [self-hosted, bazel] and replaces
s3cmd with aws CLI. Installs aws via brew if not already present.
Uses DO_SPACES credentials (same key pair the Eagle server uses to
write archived games) and macOS date syntax.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:47:30 -07:00
a50d64ee94 Disable scheduled Bazel cache cleanup (#6642)
bazel clean races with other runners sharing the output_base on
/Volumes/remote_cache, causing "Directory not empty" failures
(4 of last 5 runs). Manual trigger preserved via workflow_dispatch.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:13:26 -07:00
b8f6b99db2 Color unit labels via TMP_Text.color instead of rich-text tags (#6640)
* Color unit labels via TMP_Text.color instead of rich-text tags

SetOneUnitLabels was building <color=#...>Name</color> / <b> wrapped
strings on every update. Since each label is rendered in a single color,
setting TMP_Text.color directly is cheaper (no per-update string
concatenation, no rich-text tag parsing) and safer (type-checked Color
instead of hex string formatting). Secondary-label bold is now a font
style set once at cell creation.

SetUnitInfoLabels now takes an explicit Color parameter; all four
callers updated.

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

* Disable richText on hex-grid labels

None of the hex-grid TMP labels (hero names, battalion sizes, odds
percentages, action points, coord debug strings) use rich-text tags
after the previous commit. Disabling richText in the shared label
factory skips TMP's per-update tag-scanning pass and makes the labels
robust against accidental '<' characters in localized text.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:25:38 -07:00
c340f2b79d Color retargetable mages purple in battle UI (#6639)
Adds a third unit-label color in Shardok battles: purple (#8000FF) for a
player's mage that has already picked a meteor target but is still in the
Target state and could retarget on the same turn. Blue/black remain for
"has commands" / "no commands".

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 13:37:29 -07:00
c94422f1cf Clear focus province when faction loses ruler (#6638)
* Clear focus province when faction loses ruler to keep state valid

RuntimeValidator requires that every faction's focusProvinceId points
to a province it rules. ProvinceConqueredAction and
EndPlayerCommandsPhaseAction already emit ChangedFactionC with
clearFocusProvinceId for their respective pathways, but other pathways
(hero departures, battle casualties leaving a province unruled, etc.)
reach the applier's fixRulerIfNeeded hook without any faction-side
cleanup. The next validation pass then crashes with "Unowned focus
province for faction ...".

Clear the stale focus alongside fixRulerIfNeeded in the applier so any
ChangedProvinceC that removes or transfers a ruling faction also drops
the now-invalid focus pointers. The negative case (same ruler, just
losing some heroes) is preserved so active focuses aren't dropped
unnecessarily.

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

* Switch to end-of-phase focus cleanup instead of applier side-effect

Reverts the applier-level focus-clearing added in the previous commit.
Modifying factions as a side-effect of applyChangedProvinceC violates
the event-sourcing model: every game state change should be described
by an ActionResult, not implicit in the applier.

New approach, matching the pattern used by other
EndPlayerCommandsPhaseAction-style cleanups:

- RuntimeValidator.validateFactions now only enforces the "focus must
  be an owned province" invariant during PlayerCommands. Other phases
  may legitimately leave a stale focusProvinceId in transit (e.g.
  fixRulerIfNeeded silently clears rulingFactionId after hero
  departures or battle casualties).

- EndVassalCommandsPhaseAction is the sole entry point into
  PlayerCommands. Its final ActionResultC now carries
  ChangedFactionC(clearFocusProvinceId = true) for every faction
  whose focus province is no longer ruled by it, so the invariant
  holds when the validator re-engages.

- Removes the three applier-level tests that exercised the reverted
  side-effect, and adds EndVassalCommandsPhaseActionTest covering the
  new cleanup behavior (unruled focus, focus taken by another
  faction, and focus still held).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:10:08 -07:00
4b4a4e7dc6 Don't shatter stranded armies arriving at unoccupied provinces (#6637)
shatterStrandedArmies only checked whether an incoming army's destination
was not its own faction's territory. Since isFriendlyMove does
`province.rulingFactionId.contains(...)`, unoccupied provinces (None)
also returned false, so any already-retreated army (fleeProvinceId = None)
arriving at an unoccupied province was spuriously shattered with a
"nowhere to withdraw to" notification.

Add an explicit `rulingFactionId.isDefined` guard so arrivals at
unoccupied provinces fall through to the normal uncontested conquest
flow.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:54:55 -07:00
aba582bd10 Stop mid-stream SUBSCRIBE on stale command token to prevent duplicate updates (#6634)
TryPendingCommands' stale-token branches called StreamOneGameAsync to
"refresh state" when a pending command's token was behind the current
token. This sent a SUBSCRIBE(unfilteredCount=N) over the active stream,
which caused the server to replay history.since(N) as a new
ActionResultResponse while normal updates were still in flight.

Those re-delivered ActionResultViews were then applied a second time on
the main thread, which could surface as (e.g.) "Duplicate unaffiliated
hero N in province M" when a VASSAL_EXILED action ran twice.

The refresh is unnecessary: we only reach the stale-token branch because
an ActionResultResponse already delivered the newer token and its
updates were applied. Drop the stale command without re-subscribing.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 18:40:38 -07:00
a7d3b2b421 Fix nonsensical "over X months" in supply/feast quest descriptions (#6636)
The divination and quest-ended prompts for SendSuppliesQuest and
SpendOnFeastsQuest both said "over $componentCount months", but
componentCount on these quests equals the total food/gold (since the
counter is incremented by units delivered, not months elapsed). This
produced confusing output like "send 1337 food over 1337 months".

Drop the time clause — these quests are purely cumulative totals.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:46:59 -07:00
f6ff303ba9 Catch exceptions in ReceiveGameUpdate to avoid silent state corruption (#6633)
When an exception propagates out of an async-void lambda enqueued to
MainQueue, it goes through AsyncMethodBuilderCore.ThrowAsync to Unity's
SynchronizationContext, which logs it but cannot undo the partial state
changes already applied by the failing update. Subsequent updates then
apply diffs on top of corrupt state, cascading further errors until the
client is effectively unusable.

Wrap ReceiveGameUpdate at all four call sites in HandleGameUpdate so
exceptions are caught at the source, logged via Debug.LogException
(triggering the in-game ErrorHandler panel) and the file logger, and the
enclosing lambda completes normally. TryPendingCommands and timing logs
still run, keeping the MainQueue pump healthy.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 07:14:40 -07:00
5813bc9500 Restore missing VelociraptorColor1/2 prefabs (#6632)
VelociraptorEffect.prefab's animalPrefabs array references two
prefab GUIDs (8fa2338697777704fafc6374e9750de9 and
df9fda81573442940a2a92a3628b3cbc) from the Dino Pack Low Poly V1
asset bundle that were never committed to the repo in PR #6525.
The pack's Velociraptor.fbx, materials, and textures were added,
but the standalone .prefab files that wrap the fbx with an
Animation component and a MeshRenderer were missing.

The dangling references didn't fail until something in the live
game actually triggered ProvinceBeastsController.SpawnBeastsEffect
for a "velociraptor" beast, at which point AnimalEffect.SpawnAnimals
threw MissingReferenceException on the null array element.

Re-import the two referenced prefabs from Dino Pack Low Poly V1
(PrefabPack/PrefabSingleTexture/VelociraptorP/). The re-imported
prefab GUIDs match exactly, and the root GameObject fileIDs
(7255238702826593904 and 8644578109599837090) match the fileIDs
VelociraptorEffect.prefab already references. Internal references
(VelociraptorC1.mat material, Velociraptor.fbx mesh and animations,
VelociraptorMapAnims.controller) are all already in the repo.

The atlas-texture variant prefabs from the same pack were not
committed since nothing references them.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:02:49 -07:00
a912347dbd Serialize TryPendingCommands to prevent BAD_TOKEN bursts (#6630)
Multiple MainQueue actions (one per ActionResultResponse and
ShardokActionResultResponse) each call TryPendingCommands as
async void lambdas. When one yields at an await, the MainQueue
Update loop immediately invokes the next action, which re-enters
TryPendingCommands. Because PostRequest synchronously re-adds
the command to _pendingCommands before yielding for WriteAsync,
each parallel invocation re-drains the same buffered command and
writes it to the gRPC stream again. This produces bursts of 10+
identical writes in a single frame, all of which the server
rejects with BAD_TOKEN.

Wrap TryPendingCommands in a non-blocking SemaphoreSlim acquire.
If another invocation is already running, skip and return — any
items added to _pendingCommands while it runs will be picked up
by the next ActionResultResponse handler that fires.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:34:12 -07:00
e2221c4e45 Suppress "Reconnecting..." UX during the first retry (#6627)
Occasional disconnects are normal and usually recover on the first
retry, so the old behavior of immediately flashing "Reconnecting..."
(and a 2s countdown) on every blip was too noisy.

Now the first retry after a fresh disconnect is "silent": we don't
touch _currentState, so the status UI keeps showing Connected while
the retry attempt runs in the background. Only after the first retry
has itself failed does the ScheduleReconnect path flip state to
Reconnecting, at which point the user sees the countdown (starting
from the 4s second-attempt backoff).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:37:37 -07:00
5af91f6e8b Multiply focus province storage caps by a settable multiplier (#6628)
Introduces the FocusProvinceStorageMultiplier setting (default 1.50) and
applies it to food and gold caps when a province is its ruling faction's
focus province, so focus provinces can stockpile extra reserves.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:27:12 -07:00
bc442ba230 Add delta gradient flash to battle thermometer (#6624)
* Add delta gradient flash to battle thermometer

When the thermometer ratio changes, overlay a gradient on the delta
region showing the gaining side's color fading toward white-tinted,
making the territory shift visible. Fades out 2.5x faster than the
existing boundary flash line.

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

* Fix invisible delta gradient by using FullRect sprite mesh

The gradient sprite was using the default SpriteMeshType.Tight which
trimmed out the fully-transparent pixel, producing either an empty or
half-sized mesh. Use SpriteMeshType.FullRect with a 256x1 texture for a
cleaner gradient, and SetAsLastSibling so the overlay renders on top of
the attacker/defender bars. The boundary flash line also reasserts
last-sibling so it stays visible above the gradient.

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

* Add diagnostic logging for delta gradient

Temporary logging to diagnose why the gradient overlay isn't visible
in-game. Will be removed after diagnosis.

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

* DEBUG: render delta gradient as opaque magenta to test positioning

Temporarily replace the gradient sprite with plain opaque magenta and
add canvas renderer state logging to isolate whether the problem is
with the sprite/shader or with positioning/z-order/masking.

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

* DEBUG: use attackerBar sprite, bright green, no fade

Set the delta gradient to use the attacker bar's known-working sprite,
force opaque bright green, and disable the fade coroutine so the image
stays permanently visible. Also log the full transform path to confirm
which thermometer instance is being modified.

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

* DEBUG: test gradient sprite with permanent green tint

Swap back to the procedural 256x1 gradient sprite while keeping the
permanent no-fade green tint. This isolates whether the gradient sprite
itself renders correctly (gradient visible) or has a mesh/UV issue
(solid green or invisible).

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

* Use losing side's color for delta gradient, remove debug scaffolding

The previous implementation tinted the delta region with the *gaining*
side's color, but the bar underneath the delta region is already that
same color — so the gradient was nearly invisible (faction color fading
to a slightly lighter faction color).

Flip it around: use the *losing* side's color. The old owner's
territory briefly lingers in the lost region, then fades to reveal the
new owner's bar underneath. This is a more intuitive visualization of a
territory change.

Also flip the gradient direction so the opaque (losing-color) edge sits
at the old boundary (where the losing side's territory previously
ended), fading toward the new boundary.

Remove the debug logging, green color override, and disabled fade that
were added while diagnosing the earlier rendering issue, and re-enable
the DeltaGradientRoutine fade coroutine.

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

* Flip delta gradient direction

The losing color should be on the side of the delta closer to the
losing side's remaining bar, so it visually points "home" to its own
side. The transparent edge reveals the new owner's bar underneath.

- Attacker gained → losing=defender → opaque on right (near defender).
- Defender gained → losing=attacker → opaque on left (near attacker).

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

* Double the delta gradient fade duration

Previously 0.16s (flashDuration/2.5); now 0.32s (flashDuration/1.25)
so the gradient is easier to see.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:06:25 -07:00
3d169136a4 Fix orphaned backstory text ID for rubber-band spawned heroes (#6626)
RandomHeroGenerator.createLowPowerHero pre-populates backstoryVersions
with hero_${id}_backstory_0, but UnaffiliatedHeroAppearedAction only
emits a HeroInitialBackstoryRequest when backstoryVersions is empty. The
rubber-band spawn path in PerformUnaffiliatedHeroesAction was relying on
that action to emit the request, so the initial backstory text ID was
orphaned from the start — never registered with ClientTextStore, always
resolving to TextGenerationDependencyUnknown.

Clear backstoryVersions before handing the hero to
UnaffiliatedHeroAppearedAction so it creates a proper
hero_${id}_initial_backstory_$roundId text ID with a matching LLM
request. This restores effectiveBackstory's ability to fall back to the
initial backstory when later updates are legitimately skipped (e.g. for
heroes only visible to AI factions).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:41:49 -07:00
fae53369bf Switch default Gemini model to 3.1 Flash-Lite preview (#6625)
3.1 Flash-Lite is faster (throughput + TTFT), meaningfully smarter, and
still among the cheapest Gemini options, making it a strict upgrade
over 2.5 Flash-Lite for narrative text generation.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:00:51 -07:00
ae65205d34 Add temporary debug logging for free hero backstory visibility (#6623)
Logs a warning in GameController.withHandledEngineAndResults when a
free hero's most recent backstory is not visible to the province's
ruling faction. Includes game ID, history count, round, and phase
to help trace back to the action that caused the visibility gap.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:15:43 -07:00
d0c87c838e Add retry logic to all Addressable asset loading (#6622)
* Retry beast effect Addressable loads on failure

Beast effect prefabs are loaded from a remote CDN via Addressables.
If the download failed (network hiccup, CDN timeout), the effects
were permanently lost for the session with no retry. Now retries up
to 3 times with backoff delays (2s, 5s, 10s). Also releases failed
handles to avoid leaking resources.

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

* Extract AddressableLoader utility with retry logic for all Addressable loads

All Addressable assets load from a remote CDN and a single transient
network failure permanently broke the affected feature for the session.
Extracts retry logic (3 retries with 2s/5s/10s backoff) into a shared
AddressableLoader.LoadWithRetry<T>() utility and updates all 6 callers:
ProvinceBeastsController, TacticalAssetLoader, SoundManager,
ImageForTerrainTracker, BattleInProgressController, ProvinceActionAnimator.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 12:09:33 -07:00
6c01a81bbf Remove unused Modern UI Pack scripts, animations, and prefabs (#6621)
Removed ButtonManagerBasic and UIGradient components from the lobby
AvailableGame prefab (they were no-ops — the button already had a
normal onClick callback). Then deleted all non-texture content from
Modern UI Pack: Scripts, Animations, Editor, Fonts, Scenes, Unused
prefabs, and Documentation. Only Textures/ is retained.

Saves ~34 MB of unused assets.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:21:16 -07:00
c25ecadd60 Add editor tool to audit third-party asset usage (#6619)
* Add editor tool to audit third-party asset pack usage

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

* Include Addressable group entries in asset usage audit

The previous version only traced dependencies from build scenes, completely
missing assets loaded via Addressables. This caused ~8.5 GB of assets to
be falsely reported as unused. Now traces dependencies from all Addressable
group entries (Beast Effects, Hex Tiles, Tactical Assets, Sound Effects)
in addition to build scenes.

Also logs all USED files per pack for easier analysis.

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

* Remove Pixel Fonts Megapack from audit pack list

Pack was deleted in PR #6620.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:02:12 -07:00
251aeb5794 Remove unused Pixel Fonts Megapack asset pack (#6620)
Asset usage audit (scenes + Addressables) confirmed 0 files from this
pack are referenced anywhere in the build. Saves 2 MB.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:00:43 -07:00
9223b0452e Clear fleeProvinceId after fleeing to prevent stale flee destination (#6618)
When an army flees after losing a battle, the flee province is consumed
but was not being cleared. This caused armies arriving at a now-hostile
flee province to get attack decision options instead of being shattered.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 07:23:19 -07:00
05299dbe77 Remove debug logs from battle progress UI (#6617)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:01:45 -07:00
f06ba27a6f Fix reversed thermometer bar widths in Shardok tactical view (#6616)
The Shardok Container prefab had defender on the left and attacker on
the right, but BattleThermometer code forces the defender bar to the
right and attacker bar to the left. This caused the wide bar to appear
next to the smaller number. Swap the field references so bars and labels
are on matching sides.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:54:19 -07:00
bf90c98add Update Unity prefabs for battle progress row and editor re-serialization (#6615)
Wire up flashDuration on BattleThermometer in the Battle Progress Row
prefab and right-align the attacker troop count label. Includes Unity
editor re-serialization of Emu and peasant prefabs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:34:06 -07:00
95afb025e1 Replace inappropriate Excellent Glory headshot (#6614)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:31:26 -07:00
93e3804eef Only grant Allied hostility for actual alliances in battle views, not truces (#6613)
FactionUtils.hostilityStatus treats both truce and alliance as non-hostile,
which caused battle player infos to show Allied hostility for truce holders.
The client then displayed an "Observe" button that threw an exception on click
because the server correctly rejects observation for truce-only relationships.

Fix shardokBattlePlayerInfos to use hasAlliance directly instead of the
general hostilityStatus, so only actual alliances produce Allied hostility.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:27:32 -07:00
6c13ade07e Flash thermometer boundary on every day update (#6611)
Previously the flash only triggered when the ratio changed by more
than 0.001. Now it flashes on every update after the first, so each
day tick produces a visible flash even if the ratio barely moved.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:15:55 -07:00
dee2574a59 Fix losing armies being shattered instead of retreating to flee province (#6612)
ResolveBattleAction was discarding the original fleeProvinceId for
armies that lost a battle, causing them to shatter instead of retreating
to the province the player designated when dispatching the army.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:06:41 -07:00
8bb8a8f791 Only send BattleProgressResponse when gated round advances (#6610)
postBattleUpdate sent a BattleProgressResponse for every Shardok
action, even when the gated round hadn't changed. In AI-only battles
this floods clients with dozens of identical-day updates per round.
Now only sends battle progress when the gated round actually advances,
reducing redundant updates while still delivering Shardok action
results to participants.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:45:50 -07:00
51b95d0b62 Forward battle progress during strategic turn updates (#6609)
withHandledEngineAndResults used the 5-parameter overload of
humanClientsAfterPostingResults which did not send battle progress.
This meant non-combatant observers only received battle progress
updates when Shardok sent a GameUpdateResponse, not during Eagle
strategic turn resolution. Switch to the 7-parameter overload to
forward existing battle progress on every client update.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:45:30 -07:00
d06f26bffe Reset lastRevealedRound when all battles end (#6608)
The gated round (lastRevealedRound) used max(previous, current) to
prevent information leakage, but never reset when all battles ended.
This caused new battles to start at the previous battle's final day
(e.g. "Day 29") instead of "Day 0".

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:28:19 -07:00
7375d9f8ee Reset BattleDay to 0 when a new battle starts (#6607)
BattleDay was only set from BattleProgressResponse, so it retained
stale values from previous battles until the first progress update
arrived for the new battle.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:22:27 -07:00
0a650dec2c Add flash effect at thermometer boundary when balance shifts (#6606)
A thin white line briefly flashes at the attacker/defender boundary
whenever the troop ratio changes, giving visual feedback during battle.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 16:46:20 -07:00
22cdf99d4d Propagate isDefender flag to Shardok thermometer (#6604)
* Use isDefender flag in Shardok thermometer and game model

Use the new is_defender field from ShardokBattlePlayerInfo instead of
assuming player 0 is always the defender. Updates ShardokGameController
thermometer, ShardokGameModel.IsDefender, EagleGameModel.MakeGameModel,
and CustomBattleHandler player setup.

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

* Update Gameplay scene layout and soldier prefabs

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 15:41:38 -07:00
86e9489f21 Add is_defender to ShardokBattlePlayerInfo proto (#6605)
Add is_defender field (field 5) to ShardokBattlePlayerInfo so the
client can identify which players are defenders without assuming
player 0 is always the defender. Populate it from
ShardokPlayer.isDefender in BattleFilter and serialize it in
ShardokBattleViewConverter.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 15:20:47 -07:00
2339050154 Fix battle progress bar: swap sides and forward updates to client (#6601)
Two fixes for the battle progress thermometer:

1. Swap attacker/defender sides so attacker fills from left and defender
   from right, matching the expected layout.

2. Add missing BattleProgressResponse case in PersistentClientConnection's
   HandleGameUpdate switch. The server was sending progress updates but
   the client silently dropped them, causing the bar to stay at 50%.

Also extract UpdateBattleProgressDisplay helper in EagleGameController
and call it from SwapModel's early-return path so progress updates aren't
skipped when the player has active strategic commands.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 14:51:13 -07:00
f7fefe078e Fix crash in AttackDecisionCommandSelector when configuring toggles (#6602)
ConfigureToggle used toggle.isOn = false which fires the ToggleChanged
callback. That callback accesses SelectedDecision, which throws when no
toggle is on yet during SetUpUI. Use SetIsOnWithoutNotify instead.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 14:50:49 -07:00
ad3e7f19b9 Fix reversed troop counts in battle progress tracker (#6603)
The updatedBattleProgress method assumed player 0 was always the
defender, but RequestBattlesAction builds the players vector with
attackers first and defender last. Use the isDefender flag from the
battle's player list to correctly classify troop counts.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 14:45:25 -07:00
022bdab53f Fix ToggleGroup preventing disabled toggles from turning off (#6600)
When ConfigureToggle sets isOn=false on a toggle that is the last active
member of a ToggleGroup with allowSwitchOff=false, Unity forces it back
to true. The toggle is then removed from the group but retains the stale
isOn=true state. This causes the wrong option to be sent to the server
(e.g. Recruit when only Imprison/Return/Execute were available).

Fix by removing the toggle from its group before setting isOn=false.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 10:27:41 -07:00
c01e64265d Seed initial troop counts in BattleFilter from army battalion data (#6598)
When a battle is first created, participants now see troop counts and an
accurate balance bar immediately instead of a blank 50/50 bar while
waiting for the first Shardok GameUpdateResponse.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:25:17 -07:00
a7572a5a9a Use faction-specific colors in battle thermometer (#6597)
- BattleThermometer: accept IList<Color> for attacker colors, dynamically
  create extra bar segments for multi-attacker battles
- BattleProgressRowController: look up defender faction from province
  RulingFactionId and attacker factions from PlayerInfos, using
  LightPlayerColor for faction-correct colors; use DarkColoredProvinceName
  for more readable province name text
- ShardokGameController: wrap single attacker color in list for new API

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:03:45 -07:00
44edd8cc5e Clear hex overlays on every model update and turn end (#6596)
ModelUpdated() never unconditionally cleared overlays, so stale highlights
persisted after phase transitions (setup -> running) and after issuing a
command that ends the turn. Add ClearOverlays() at the top of ModelUpdated()
so overlays are always wiped before being selectively re-drawn, and in
EndTurn() for immediate visual feedback when clicking Commit/End Turn.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 22:03:13 -07:00
3a1f1c2bbe Add battle progress display for non-combatant observers (#6581)
* Add battle progress tracking and real-time progress updates

During BATTLE_RESOLUTION phase, track Shardok battle progress (troop counts
and defender/attacker ratios) and push synchronized updates to clients via
a new BattleProgressResponse message. All battles advance together using
gated round progression to prevent information leakage. Allied observers
see troop counts; non-allied players see only the ratio bar.

Key changes:
- Add defender_ratio, troop counts, and winning_faction_id to ShardokBattleView proto
- Add updated_battles to GameStateViewDiff proto
- Add BattleProgressResponse to GameUpdate oneof
- Track battle progress in GameController with synchronized round gating
- Send enriched battle views to clients via HumanPlayerClientConnectionState
- Update GameStateViewDiffer to detect battle updates
- Add GameStateViewDifferTest with 6 test cases

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

* Move BattleProgressResponse to views layer to fix proto layering

BattleProgressResponse only contains ShardokBattleView objects, which
belong to the views layer. Move it from eagle.proto (api layer) to
game_state_view.proto (views layer) and remove the direct
shardok_battle_view.proto import from eagle.proto.

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

* Add battle progress display for non-combatant observers

During BATTLE_RESOLUTION, non-combatant players now see real-time
progress bars instead of a single "Observe Battle" button. Each bar
shows defender/attacker balance with green/red coloring. Allied battles
show troop counts and an Observe button; non-allied battles show only
the bar. When a battle ends, the bar is replaced with Victory/Defeat.

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

* Fix TextAlignmentOptions.MidlineCenter compile error and meta GUID conflict

MidlineCenter does not exist in Unity 6 TMP; use Center instead.
Also include reassigned .meta GUID to resolve conflict with duel.asset.

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

* Add BattleProgressPanel GameObject to Gameplay scene

Wire the panel to EagleGameController.battleProgressPanel with
VerticalLayoutGroup and ContentSizeFitter components.

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

* Remove BattleInProgressController from BattleProgressPanel GameObject

BattleInProgressController belongs on the map, not the progress panel.

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

* Remove unused ShardokGameStateView import from GameController

The import triggered -Werror and broke the battle_progress_test build.

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

* Show BattleProgressPanel for all battles, not just observed ones

The panel was only activated for non-combatant observers. Now it shows
whenever there are battles, alongside the "Battle!" button for combatants.

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

* Use ShardokBattles instead of RunningShardokGameModels for panel visibility

RunningShardokGameModels requires the client to have connected to the
Shardok game and received a GameRunning status, which may not have
happened yet. ShardokBattles comes from the server game state view and
is available immediately when battles exist.

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

* Refactor BattleProgressPanel to use prefab rows and display battle day

Replace programmatic CreateRow()/PopulateRow() with a prefab-based
approach using BattleProgressRowController. The panel now instantiates
rows from a prefab into a scroll view, displays "Day X" from the
server's gated round, and uses colored province names.

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

* Use BattleThermometer in battle progress rows

Replace manual anchor/color logic with the existing BattleThermometer
component, which already handles both troop-count and ratio-only modes.

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

* Fix row container cleanup, observe button visibility, and prefab transforms

Clear stale children from rowContainer on first update. Only show
Observe button for allied battles (not the player's own). Fix bar
rotation/scale in row prefab. Add debug logging for battle progress.

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

* Update BattleProgressPanel layout in Gameplay scene

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

* Replace goToBattleButton with per-row Fight!/Waiting.../Observe buttons

Each battle row now has its own action button: "Fight!" for the
fightable battle, "Waiting..." (grayed) for other own battles,
and "Observe" for allied battles. The global goToBattleButton is removed.

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

* Remove goToBattleButton from scene and adjust battle panel layout

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

* Adjust battle progress panel layout

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:31:16 -07:00
0f3ffc24c2 Send gated round as current_day in BattleProgressResponse (#6595)
The server already computes lastRevealedRound (the minimum round across
all active battles) for fair reveal timing, but never sent it to the
client. Add current_day field to the proto and pass gatedRound through
sendBattleProgress so clients can display the battle day.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:56:53 -07:00
741efecc66 Fix AnimalEffect spamming "State could not be found" errors (#6594)
Guard idle and walk Play() calls with HasState(), matching the existing
pattern already used for eat/attack transitions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:10:55 -07:00
4c7ac587e7 Add Scala battle progress tracking for non-combatant observers (#6585)
* Add battle progress tracking with proto-based troop counts

Adds real-time battle progress tracking so non-combatant observers can
see defender/attacker troop ratios during BATTLE_RESOLUTION phase.

C++ Shardok extracts current_round and per-player troop counts from the
FlatBuffer game state and sends them as new proto fields on
GameUpdateResponse, avoiding the previous approach of parsing opaque
FlatBuffer bytes as Protobuf on the Scala side.

Scala Eagle tracks progress via BattleProgressTracker, gates round
reveals across concurrent battles, and enriches ShardokBattleView with
defender_ratio and troop counts for allied observers.

Includes tests for both C++ troop count extraction and Scala progress
tracking logic.

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

* Remove default values from ShardokBattleView fields

Make all new fields (defenderRatio, defenderTroopCount,
attackerTroopCount, winningFactionId) required at construction sites
instead of relying on defaults.

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

* Preserve battleProgress across GameController operations

Convert withHumanClient, withAiClient, stopStreaming, aiClientCommands,
and withHandledEngineAndResults from explicit GameController construction
to copy(), so battleProgress and lastRevealedRound are preserved when
clients connect/disconnect or commands are processed.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:10:13 -07:00
3c18918634 Extract battle progress data from FlatBuffer game state in Shardok (#6593)
Populates the new proto fields (current_round, player_troop_counts) on
GameUpdateResponse by extracting data from the FlatBuffer game state in
ShardokGameController::GetUpdates(). Counts troops for each player
across NORMAL, RESERVE, and NEVER_ENTERED units.

Updates the OnUpdates callback signature to pass the new fields through
EagleInterfaceGrpcServer to Eagle.

Includes tests verifying troop count extraction for both normal games
and games with reserved slots.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:52:23 -07:00
b930ca626f Add proto fields for battle progress tracking (#6592)
Adds new proto fields to support real-time battle progress updates for
non-combatant observers:

- shardok_internal_interface: PlayerTroopCount message, current_round
  and player_troop_counts on GameUpdateResponse
- shardok_battle_view: defender_ratio, troop counts for allied viewers,
  winning_faction_id
- game_state_view: updated_battles on GameStateViewDiff,
  BattleProgressResponse message
- eagle.proto: battle_progress_response in GameUpdate oneof
- BUILD.bazel: game_state_view_scala_proto target

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:45:11 -07:00
d1219787a8 Add weekly S3 archive cleanup workflow (#6591)
Deletes game folders from eagle/archived/ where directory.e0i is over
1 month old. Runs weekly on Sundays at 05:00 UTC with manual trigger support.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:39:59 -07:00
8019986ea7 Populate "Your Army" table in Battle Aftermath panel (#6590)
The surviving units data was being sent by the server but never displayed.
Wire the EventBasedTable to show heroes and battalions using the same
CombatUnitRowController pattern as AttackDecisionCommandSelector.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:30:06 -07:00
62b7e3abe1 Fix "What's New" showing same entries on every sign-in (#6588)
MarkAllAsSeen() assumed entries[0] had the latest date, but entries
in whats-new.json aren't necessarily in chronological order. When a
backdated entry was added at position 0, the saved last-seen date was
older than other entries, causing them to reappear on every sign-in.

Now scans all entries to find the maximum date.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:09:21 -07:00
88b049334b Archive S3 game files on game removal (#6589)
When a game is archived (last player exits) or deleted (admin console),
S3 files under eagle/save/{gameId}/ are now moved to eagle/archived/{gameId}/
to prevent the lazy-loader from resurrecting stale data and to keep S3 clean.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:07:21 -07:00
0aaa726abb Preserve schemaVersion when saving loaded games (#6586)
* Preserve schemaVersion when saving loaded games to games.e0es

GamesManager.save() was building RunningGame entries for loaded games
without setting schemaVersion, causing it to default to 0. This made
every game appear as needing migration on every server restart after
being loaded into memory, even when the schema version hadn't changed.

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

* Also stamp currentSchemaVersion on newly created games

Pass GameMigrator.currentVersion into GamesManager so new games get the
correct schema version on their first save, avoiding a needless no-op
migration on the next server restart.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:57:33 -07:00
37f75d4f6b Fix CompoundPersister.delete to remove from all backends (#6587)
delete() and deleteAll() only removed from the first (local) persister,
leaving stale files in S3. This caused rewound shardok battles to be
resurrected via lazy-loading from S3.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:44:47 -07:00
adminandGitHub b35a4b834e Revert "Add battle progress tracking and real-time updates (#6580)" (#6584)
This reverts commit 4fdc97590b.
2026-04-01 17:15:38 -07:00
adminandGitHub 01a19f740d Revert "Fix battle progress parsing FlatBuffer bytes as Protobuf (#6582)" (#6583)
This reverts commit f86f53326f.
2026-04-01 17:14:45 -07:00
f86f53326f Fix battle progress parsing FlatBuffer bytes as Protobuf (#6582)
* Fix battle progress parsing FlatBuffer bytes as Protobuf

The updatedBattleProgress method was calling ShardokGameStateView.parseFrom()
on opaque FlatBuffer bytes, causing InvalidProtocolBufferException in production.

Fix: Add current_round and player_troop_counts fields to GameUpdateResponse
proto so Shardok serializes the data explicitly. Eagle reads from these new
proto fields instead of trying to parse the opaque game state bytes.

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

* Add tests for battle progress proto fields

C++ test verifies troop count extraction from FlatBuffer game state
matches GetUpdates() logic. Scala test covers updatedBattleProgress()
for tracker creation, same-round updates, round boundary snapshots,
multi-attacker summation, and missing defender defaults.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 12:05:52 -07:00
4fdc97590b Add battle progress tracking and real-time updates (#6580)
* Add battle progress tracking and real-time progress updates

During BATTLE_RESOLUTION phase, track Shardok battle progress (troop counts
and defender/attacker ratios) and push synchronized updates to clients via
a new BattleProgressResponse message. All battles advance together using
gated round progression to prevent information leakage. Allied observers
see troop counts; non-allied players see only the ratio bar.

Key changes:
- Add defender_ratio, troop counts, and winning_faction_id to ShardokBattleView proto
- Add updated_battles to GameStateViewDiff proto
- Add BattleProgressResponse to GameUpdate oneof
- Track battle progress in GameController with synchronized round gating
- Send enriched battle views to clients via HumanPlayerClientConnectionState
- Update GameStateViewDiffer to detect battle updates
- Add GameStateViewDifferTest with 6 test cases

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

* Move BattleProgressResponse to views layer to fix proto layering

BattleProgressResponse only contains ShardokBattleView objects, which
belong to the views layer. Move it from eagle.proto (api layer) to
game_state_view.proto (views layer) and remove the direct
shardok_battle_view.proto import from eagle.proto.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 21:45:35 -07:00
1d46bdd452 Fix Sparkle sign_update path to match tarball structure (#6579)
The Sparkle-2.6.4.tar.xz extracts to ./bin/sign_update, not
./Sparkle-2.6.4/bin/sign_update. This broke mac builds when the
runner had a fresh /tmp/sparkle-cache.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:51:29 -07:00
292097f03e Fix Please Recruit Me not highlighting the acting province (#6578)
new List<ProvinceId>(heroInfo.provinceId) calls the capacity constructor,
creating an empty list with capacity = provinceId. Changed to collection
initializer syntax { heroInfo.provinceId } so the province ID is actually
added as an element. The PopupPanelController already feeds this list into
mapController.OverrideTargetedProvinces for highlighting, but the list was
always empty.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 06:39:13 -07:00
9a8ef4f243 Log command details when postCommand fails (#6577)
Include game ID, command type, and province/player info in the error
log line so failed commands are diagnosable without reproducing.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 21:03:13 -07:00
c99d45bda7 Fix smuggler figures rendering white by using prefab refs instead of raw FBX (#6576)
SoldierEffect.prefab referenced 3 of 4 character models as raw .fbx files
(PT_Male_Soldier_01.fbx, PT_Female_Soldier_01.fbx, PT_Female_Soldier_02.fbx).
Raw FBX instantiation doesn't carry material assignments, causing models to
render with the default white material. Updated references to use the proper
.prefab files from Polytope Studio which have materials correctly configured.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 21:02:05 -07:00
18222f9cc4 Upgrade GitHub Actions to Node.js 24 versions (#6575)
Node.js 20 actions are deprecated and will stop working June 2, 2026.

- actions/checkout v4 → v6
- actions/upload-artifact v4 → v7
- actions/download-artifact v4 → v8
- actions/setup-node v4 → v6
- appleboy/ssh-action v1.0.3 → v1.2.5
- docker/setup-qemu-action v3 → v4
- docker/setup-buildx-action v3 → v4

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 07:51:55 -07:00
adminandGitHub 344f1134f6 Detect missing prefab references in Unity builds (#6574) 2026-03-22 18:20:09 -07:00
9aaca0d993 Add diagnostic for Windows-only NullRef in SettingsPanelController (#6573)
All prefab override fields are null at runtime on Windows builds only.
Log the missing fields and gracefully bail out of ApplyPersistedSettings,
Start, and HandleEscape so the game can still launch and we can
investigate whether other prefab overrides are also broken.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:43:39 -07:00
94db3634db Add artifact cleanup job to purge stale logs every 6 hours (#6572)
GitHub Actions retention-days: 3 is unreliable — 1,846 expired artifacts
(282 MB) accumulated over 5+ weeks. Add a cleanup-expired job that deletes
artifacts older than 3 days before the storage check runs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:17:19 -07:00
adminandGitHub 19c8e63152 Replace necromancer control chain with interlocking ring links (#6571) 2026-03-21 16:22:34 -07:00
5d7f298d1c Suppress harmless stream-closed exceptions from Sentry (#6570)
* Suppress harmless stream-closed IllegalStateException in SyncResponseObserver

The most common Sentry exception was "Stream is already completed" from
CustomBattleManager. This happens when Shardok sends battle results to a
client that already disconnected — the isCancelled check passes because
"cancelled" and "completed" are different stream states, so the underlying
onNext throws IllegalStateException. Catch it silently in all three stream
methods, matching the existing pattern in GameController.

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

* Narrow catch to only suppress "already completed" stream exceptions

Only catch IllegalStateException when the message contains "already completed",
so unexpected IllegalStateExceptions still propagate to Sentry.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:10:25 -07:00
1cfd0ded6c Narrow longbow wind bonus cone from 120° to 60° (#6569)
The wind-assisted range-3 check was allowing tiles in adjacent hex
directions (±60° from wind), producing a 120° cone. This let longbows
shoot northwest with a northeast wind. Tighten the check so only tiles
whose direction matches the wind direction (or sits on the boundary
between two sectors, one of which is the wind direction) qualify.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:53:26 -07:00
ab69d40e64 Fix control undead animation targeting wrong location (#6568)
The control command's CommandDescriptor has target_unit but no target
coords, so the client treated it as fully untargeted and animated toward
the nearest enemy instead of the controlled undead unit. Now
PlayUntargetedCommandAnimation uses the command's target_unit when
available. Also set target_unit on the CONTROLLED action result so
server-side history replays animate correctly.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:48:00 -07:00
be94acebbf Remove noisy MeteorStart Debug.Log from ShardokGameController (#6567)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:33:29 -07:00
4f5df7d697 Remove noisy SHA256 validated Debug.Log messages from ProvinceIDLoader (#6566)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:48:47 -07:00
c5af75db7d Remove noisy Debug.Log messages from ProvinceBeastsController (#6565)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:23:10 -07:00
24421db613 Defer beast effect spawning until province centroids are loaded (#6564)
The ProvinceBeastsController could attempt to spawn beast effects before
centroids arrived from CDN, causing "No centroid found" warnings. The
effect prefabs (loaded from local Addressables) often finished loading
before the centroids (fetched over the network), creating a window where
UpdateBeastsEffects would proceed without centroid data.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:12:19 -07:00
35a804cf18 Fix AnimalEffect animator state errors (#6563)
* Guard AnimalEffect animator state plays with HasState check

Some animal prefab animator controllers don't have states named idle,
walk, eat, or attack, causing spammy "State could not be found" and
"Invalid Layer Index '-1'" errors every frame. Added SafePlay helper
that checks HasState before calling Play.

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

* Fix AnimalEffect animator state errors for kangaroo, leopard, black panther

Three animator controllers (KangarooMapAnims, LeopardMapAnims,
BlackPantherMapAnims) used "idle1" instead of "idle", causing
"State could not be found" errors every frame. Renamed to "idle".

Also skip eat/attack actions for animals whose controllers don't have
those states (e.g. scorpion has no eat) — falls back to idle instead
of playing a missing state.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:04:14 -07:00
ba9091409a Skip auto-placement of reserve units during reconnect resync (#6562)
On reconnect, HandleStartingState clears all ShardokGameModels and
re-creates them, then the server replays the full battle history. Each
ChangedReserveUnit in the replayed history was triggering auto-placement
to starting positions, causing visible UI thrashing — especially with
repeated reconnect attempts during idle periods. The user's manual
placements (local-only until submitted) were lost each time.

Added SkipAutoPlacement flag to ShardokGameModel, set during resync
model creation and cleared after the first successful HandleUpdates.
This prevents auto-placement during history replay while preserving
it for genuinely new battles.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:27:37 -07:00
b4452d4e9c Fix tribute sliders not activating in Attack Decision command (#6561)
The 4 decision toggles (Advance, Demand Tribute, Withdraw, Safe Passage)
were not wired to ToggleChanged in the prefab, so selecting Demand Tribute
never activated the tribute container with gold/food sliders. Also changed
ConfigureToggle to use SetIsOnWithoutNotify when disabling unavailable
toggles, preventing a "No decision selected" exception during setup.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:32:08 -07:00
adminandGitHub 32f5c6838c Add externalObjects material remapping to animal/creature FBX meta files (#6560) 2026-03-20 12:45:34 -07:00
8fa13da9c1 Add URP migration plan for BiRP-to-URP pipeline conversion (#6559)
Documents phased approach for migrating from Built-In Render Pipeline
to Universal Render Pipeline before Unity drops BiRP support after 6.7.
Covers shader inventory, long-lived branch strategy, and effort estimates.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:24:08 -07:00
a2958a7672 Fix backstory visibility for unaffiliated heroes after conquest (#6558)
Unaffiliated hero backstory updates were only visible to the current
province owner, so when another faction conquered and tried to recruit,
the backstory was missing. Now unaffiliated hero backstory updates use
empty recipientFactionIds (meaning all factions), matching the initial
backstory behavior.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:03:38 -07:00
1eb9f07c9c Unity 6.4 upgrade (#6557)
* Upgrade Unity from 6000.3.8f1 to 6000.4.0f1

Also clear the CI runner's Library/ cache when the Unity version
changes to prevent infinite import loops on version upgrades.

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

* Fix em dash characters in workflow YAML breaking GitHub Actions

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

* Fix gray textures after Unity 6.4 upgrade by switching to embedded materials

materialLocation: 0 (External) is deprecated in Unity 6.4 and causes FBX
models to render without textures. Changed all 749 .fbx.meta files to use
materialLocation: 1 (Embedded) which is the new default.

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

* Trigger CI checks

* Add trailing newline to workflow file to re-trigger CI

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

* Fix YAML syntax error on line 125 of unity_build.yml

The run: value contained | and > characters that confused the YAML parser.
Switching to block scalar syntax (run: |) avoids the ambiguity.

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

* Remap bridge materials to fix gray textures after materialLocation change

With materialLocation: 1 (Embedded), Unity ignores external .mat files.
Added externalObjects remapping so the bridge FBX models still use the
correctly-textured bridges_d material from TileableBridgePack/Materials/.

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

* Add material remapping to all remaining bridge FBX meta files

The old-format meta files (serializedVersion: 18) had no materialLocation
or externalObjects fields, causing Unity 6.4 to default to the deprecated
External material path and render bridges gray. Added externalObjects
remapping and materialLocation: 1 to all 47 remaining bridge models.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:14:43 -07:00
393fe7bdca Add rounded corner mask to battle thermometer bar area (#6555)
Use UISprite with Mask component on the Bar Area container to clip
the colored bars with smooth rounded corners.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:44:42 -07:00
51fc0ac99c Add battle thermometer to Shardok tactical view (#6554)
* Add battle thermometer to Shardok tactical view

Replace the always-visible armies table with a horizontal thermometer bar
showing defender vs attacker troop balance at a glance. The bar uses
Fantasy RPG boss gauge sprites with faction-matched colors. Hovering the
thermometer reveals the full armies table as a popup.

Also adds icons to Army Info Row and removes the totals row from the
armies table since troop counts are now shown on the thermometer.

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

* Fix thermometer bar sizing and null model crash

Use anchor-based horizontal sizing instead of fillAmount, preserving
vertical inspector settings. Replace BarMask with plain Bar Area
container. Guard UpdateReserves against null Model when Shardok
container is active at game launch.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:15:15 -07:00
f3211cf239 Replace Dumb Edged-Rod headshot with one that shows her face (#6553)
The previous headshot only showed her torso up to her lips.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 19:28:54 -07:00
a73e758551 Remove unnecessary DontDestroyOnLoad from UpdateNotificationManager (#6552)
UpdateNotificationManager lives in Gameplay.unity which is the
persistent base scene — it is never unloaded. DontDestroyOnLoad is
unnecessary and produces a warning because the object is a non-root
UI child under a Canvas. Remove the call entirely.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 06:32:20 -07:00
b47e37a083 Remove noisy startup Debug.Log messages (#6551)
Remove happy-path confirmation logs that fire on every normal startup
and provide no diagnostic value: tutorial UI built, state loaded, token
cache initialized, Sparkle version/platform info, auth configured,
session restored, and WhatsNew check results.

Error/warning logs (LogWarning, LogError) are preserved.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:48:48 -07:00
bccd861230 Fix Unity 6 compiler warnings across 30 C# files (#6548)
Replace deprecated APIs to eliminate non-third-party warnings:
- FindObjectOfType<T>() → FindAnyObjectByType<T>() (23 files)
- FindObjectsOfType<T>() → FindObjectsByType<T>(FindObjectsSortMode.None) (1 file)
- enableWordWrapping → textWrappingMode (3 files)
- Add _ = discard to unawaited async Task.Run calls (2 files)
- Remove unused variable in TutorialManager

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:25:25 -07:00
2771 changed files with 23211 additions and 520440 deletions
@@ -7,7 +7,39 @@ on:
workflow_dispatch:
jobs:
cleanup-expired:
runs-on: ubuntu-latest
steps:
- name: Delete artifacts older than 3 days
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Fetching all artifacts..."
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.id)\t\(.created_at)\t\(.name)"' > /tmp/all_artifacts.txt
total=$(wc -l < /tmp/all_artifacts.txt)
echo "Found $total total artifacts"
cutoff=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
echo "Deleting artifacts created before $cutoff"
deleted=0
while IFS=$'\t' read -r id created_at name; do
if [[ "$created_at" < "$cutoff" ]]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" 2>/dev/null && deleted=$((deleted + 1))
if [ $((deleted % 50)) -eq 0 ]; then
echo "Deleted $deleted artifacts so far..."
fi
fi
done < /tmp/all_artifacts.txt
echo "Cleanup complete. Deleted $deleted expired artifacts out of $total total."
rm -f /tmp/all_artifacts.txt
check-storage:
needs: cleanup-expired
runs-on: ubuntu-latest
steps:
+3 -3
View File
@@ -31,7 +31,7 @@ jobs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
@@ -127,10 +127,10 @@ jobs:
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
+6 -4
View File
@@ -1,9 +1,11 @@
name: Bazel Cache Cleanup
on:
schedule:
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
# Disabled: bazel clean fails when another runner shares the output_base
# on /Volumes/remote_cache (unlinkat "Directory not empty" race).
# See https://github.com/nolen777/eagle0/issues/TBD for details.
# schedule:
# - cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
@@ -12,7 +14,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
+5 -5
View File
@@ -41,13 +41,13 @@ jobs:
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
- name: Check JavaScript syntax
@@ -63,7 +63,7 @@ jobs:
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
- name: Run tests
@@ -102,14 +102,14 @@ jobs:
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
- name: Archive test results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: test.json
path: test.json
retention-days: 3
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: failed-test-logs
path: failed_test_logs/
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
lfs: false
clean: false
+6 -6
View File
@@ -27,13 +27,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
@@ -81,21 +81,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build ARM64 sysroot
run: ./tools/sysroot/build_sysroot_arm64.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
+4 -4
View File
@@ -70,7 +70,7 @@ jobs:
- name: Checkout repository
if: steps.check-latest.outputs.skip != 'true'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
@@ -117,7 +117,7 @@ jobs:
- name: Upload warmup binary
if: steps.check-latest.outputs.skip != 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: warmup-binary
path: scripts/bin/warmup
@@ -245,7 +245,7 @@ jobs:
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
@@ -257,7 +257,7 @@ jobs:
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Download warmup binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: warmup-binary
path: scripts/bin/
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
- name: Build Eagle server
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
lfs: false
clean: false
@@ -50,7 +50,7 @@ jobs:
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: eagle-installer
path: ./installer-output/
+3 -3
View File
@@ -122,7 +122,7 @@ jobs:
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
@@ -164,7 +164,7 @@ jobs:
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
@@ -264,7 +264,7 @@ jobs:
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
if: success() && github.event.inputs.skip_upload == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: eagle0-ios-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
+8 -8
View File
@@ -81,7 +81,7 @@ jobs:
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
@@ -227,7 +227,7 @@ jobs:
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
@@ -235,7 +235,7 @@ jobs:
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
@@ -250,7 +250,7 @@ jobs:
runs-on: [self-hosted, macOS, notarize]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
sparse-checkout: scripts
@@ -258,7 +258,7 @@ jobs:
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
@@ -286,7 +286,7 @@ jobs:
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
@@ -303,7 +303,7 @@ jobs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # For version numbering
@@ -311,7 +311,7 @@ jobs:
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
+86
View File
@@ -0,0 +1,86 @@
name: S3 Archive Cleanup
on:
schedule:
# Run weekly on Sunday at 05:00 UTC
- cron: '0 5 * * 0'
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
jobs:
cleanup:
runs-on: [self-hosted, bazel]
steps:
- name: Ensure AWS CLI is available
run: |
if ! command -v aws &> /dev/null; then
brew install awscli
fi
- name: Delete old archived game folders
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DO_SPACES_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
run: |
set -euo pipefail
S3_ENDPOINT="https://sfo3.digitaloceanspaces.com"
BUCKET="s3://eagle0/eagle/archived/"
# macOS date syntax
CUTOFF=$(date -v-1m +%s)
DELETED=0
SKIPPED=0
NO_DIR_FILE=0
echo "Cutoff date: $(date -r ${CUTOFF} '+%Y-%m-%dT%H:%M:%S')"
echo ""
FOLDERS=$(aws s3 ls "$BUCKET" --endpoint-url "$S3_ENDPOINT" \
| awk '/PRE/{gsub(/\/$/,"",$2); print $2}')
if [ -z "$FOLDERS" ]; then
echo "No archived folders found."
exit 0
fi
TOTAL=$(echo "$FOLDERS" | wc -l | tr -d ' ')
CURRENT=0
for game_id in $FOLDERS; do
CURRENT=$((CURRENT + 1))
DIR_INFO=$(aws s3 ls "${BUCKET}${game_id}/directory.e0i" \
--endpoint-url "$S3_ENDPOINT" 2>/dev/null || true)
if [ -z "$DIR_INFO" ]; then
echo "[$CURRENT/$TOTAL] DELETE $game_id (no directory.e0i)"
aws s3 rm --recursive "${BUCKET}${game_id}/" \
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
DELETED=$((DELETED + 1))
NO_DIR_FILE=$((NO_DIR_FILE + 1))
continue
fi
FILE_DATE=$(echo "$DIR_INFO" | awk '{print $1 " " $2}')
FILE_EPOCH=$(date -j -f '%Y-%m-%d %H:%M:%S' "$FILE_DATE" +%s 2>/dev/null || echo "0")
if [ "$FILE_EPOCH" -lt "$CUTOFF" ]; then
echo "[$CURRENT/$TOTAL] DELETE $game_id (directory.e0i from $FILE_DATE)"
aws s3 rm --recursive "${BUCKET}${game_id}/" \
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
DELETED=$((DELETED + 1))
else
echo "[$CURRENT/$TOTAL] KEEP $game_id (directory.e0i from $FILE_DATE)"
SKIPPED=$((SKIPPED + 1))
fi
done
echo ""
echo "=== Summary ==="
echo "Total folders: $TOTAL"
echo "Deleted: $DELETED"
echo "Kept (recent): $SKIPPED"
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: false
- name: Build Shardok server
+27 -8
View File
@@ -69,7 +69,7 @@ jobs:
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
@@ -77,21 +77,35 @@ jobs:
- name: Clean stale files
run: |
git clean -ffd
LIBRARY_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library"
VERSION_CACHE="$LIBRARY_DIR/.last_unity_version"
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
CURRENT_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
# Nuke Library/ when Unity version changes to avoid import loops
if [ -f "$VERSION_CACHE" ]; then
CACHED_VERSION=$(cat "$VERSION_CACHE")
if [ "$CACHED_VERSION" != "$CURRENT_VERSION" ]; then
echo "Unity version changed ($CACHED_VERSION -> $CURRENT_VERSION) -- clearing Library/"
rm -rf "$LIBRARY_DIR"
fi
fi
# Only clear Bee/ when C# files were added/deleted/renamed (structural
# changes that stale the DAG). Content-only modifications are handled by
# Bee's incremental compilation. See persist_library.sh for background.
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
BEE_DIR="$LIBRARY_DIR/Bee"
SHA_FILE="$LIBRARY_DIR/.last_built_sha"
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
LAST_SHA=$(cat "$SHA_FILE")
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
echo "C# files added/deleted/renamed since $LAST_SHA clearing Bee/"
echo "C# files added/deleted/renamed since $LAST_SHA -- clearing Bee/"
rm -rf "$BEE_DIR"
else
echo "No structural C# changes since $LAST_SHA keeping Bee/"
echo "No structural C# changes since $LAST_SHA -- keeping Bee/"
fi
else
echo "No previous build SHA or no Bee/ clearing Bee/ as safe default"
echo "No previous build SHA or no Bee/ -- clearing Bee/ as safe default"
rm -rf "$BEE_DIR"
fi
@@ -106,6 +120,11 @@ jobs:
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Save Unity version for Library/ cache invalidation
if: success()
run: |
grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //' > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_unity_version
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
@@ -152,7 +171,7 @@ jobs:
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: editor_win.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
@@ -172,4 +191,4 @@ jobs:
run: |
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
+16
View File
@@ -38,3 +38,19 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
+16
View File
@@ -53,6 +53,22 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
exit $UNITY_EXIT_CODE
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+16
View File
@@ -38,3 +38,19 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
+7 -6
View File
@@ -42,16 +42,17 @@ Time-to-first-token (TTFT) was measured from request initiation to the first tex
### For Narrative Text Generation (Default)
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
**Gemini 3.1 Flash-Lite** is recommended as the default:
- Priced at $0.25/$1.50 per 1M input/output tokens
- Significantly faster than 2.5 Flash-Lite on throughput and TTFT
- Meaningfully smarter than 2.5 Flash-Lite, while remaining one of the cheapest options
- Quality is more than sufficient for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Cost | Gemini 3.1 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
@@ -70,7 +71,7 @@ LLM settings can be changed at runtime via the admin console:
1. Navigate to Admin Console → Settings
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
3. Change the corresponding model name setting:
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `GeminiModelName` (default: gemini-3.1-flash-lite-preview)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
+7 -8
View File
@@ -51,7 +51,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
### Basic Gameplay
- [ ] Tutorial
- [ ] Tutorial & first-session onboarding
- [x] In the Your Warlord panel, say what the profession is
- [x] ~~And separate panels for each profession when you encounter one~~
- [x] ~~Command tutorial for each command the first time it's clicked~~
@@ -62,17 +62,16 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x] ~~Time to swear brotherhood~~
- [x] ~~When you get large, or~~
- [x] ~~When you get a good candidate~~
- [ ] Shardok tutorial!
- [x] ~~Shardok tutorial!~~
- [x] ~~Narrative hook in first few minutes - why should I care about my warlord?~~
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [x] ~~Early small victory to build momentum~~
- [ ] Guided first scenario vs. overwhelming sandbox?
- [ ] Basic Shardok AI stuff fixed
- [ ] Lobby fixes
- [x] ~~Lobby fixes~~
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
- [ ] First-session onboarding (beyond mechanics tutorial)
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [ ] Early small victory to build momentum
- [ ] Guided first scenario vs. overwhelming sandbox?
## Nice to have
+1 -1
View File
@@ -32,7 +32,7 @@ When a new player starts their first game in tutorial mode, they experience:
- 1x Heavy Cavalry (600 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
- **Origin Province**: 31
### Flee Trigger
Attacker flees when ANY of these conditions are met:
+322
View File
@@ -0,0 +1,322 @@
# Tutorial System Architecture
This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs:
- **`TUTORIAL_CONTENT.md`** — human-facing copy for tutorial steps and dialogues
- **`TUTORIAL_BATTLE_SYSTEM.md`** — the scripted first-battle scenario (Tarn vs. John Ranil)
It also overlaps slightly with the in-tree `Assets/Tutorial/TUTORIAL_PLAN.md`, which is an older implementation plan.
All paths below are relative to `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/`.
---
## Current State (important)
There are **two parallel subsystems** that share the same trigger plumbing:
| Subsystem | Status | Lives in |
|-----------|--------|----------|
| Step-based tutorial UI (modals, overlays, hints) | **Dormant** | `Tutorial/TutorialManager.cs`, `Tutorial/UI/*`, `Tutorial/Content/*` |
| Narrative dialogue system | **Live** | `Tutorial/Dialogue/*`, `Resources/Dialogues/*.json` |
`TutorialContentDefinitions.RegisterAll()` returns early at line 17 with the comment *"Old tutorial content suppressed — replaced by narrative dialogue system."* As a result:
- `OnboardingSequence` is never assigned, so `StartOnboarding()` no-ops at the "No onboarding sequence assigned" branch.
- No contextual tutorial sequences are registered with the trigger registry.
- The trigger registry still fires events normally, but nothing on the step-UI side is listening.
- `DialogueManager` consumes those same events and matches them against scripts in `Resources/Dialogues/` (`tutorial_strategic.json`, `tutorial_battle.json`).
**In practice today, the entire active tutorial experience runs through the dialogue system.** The step-UI machinery is preserved for future use — content definitions still exist below the early return in `TutorialContentDefinitions.cs` for reference.
---
## Class Map
### Orchestration
- **`Tutorial/TutorialManager.cs`** — Singleton. Owns `OnboardingSequence`, the active step queue, the trigger registry, and the dialogue manager handle. Initialized by `EagleGameController.SetUpGame()` and `ShardokGameController.SetUpGame()`.
- **`Tutorial/TutorialState.cs`** — PlayerPrefs persistence. Tracks `OnboardingCompleted`, `OnboardingStepReached`, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference.
- **`Tutorial/TutorialTargetRegistry.cs`** — Maps string IDs to `RectTransform`s for highlighting. Static targets are Inspector-assigned (`ProvinceInfoPanel`, `SupportField`, `CommitButton`, etc.); dynamic targets register at runtime (e.g., hero rows from `HeroesAndBattalionsPanelController`).
### Triggers
- **`Tutorial/Triggers/TutorialTriggerRegistry.cs`** (~1100 lines) — Routes game events to both the step UI (when sequences are registered) and `DialogueManager`. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice.
### Content (step UI — dormant)
- **`Tutorial/Content/TutorialStep.cs`** — Per-step record: `DisplayMode`, `CompletionType`, target path, copy, panel anchor, highlight options.
- **`Tutorial/Content/TutorialSequence.cs`** — `ScriptableObject` holding ordered `TutorialStep`s plus `IsOnboarding` flag and lifecycle callbacks.
- **`Tutorial/Content/TutorialContentDefinitions.cs`** — Static class that *would* register all sequences. Currently short-circuits before any registration.
### UI (step UI — dormant)
- **`Tutorial/UI/TutorialUIManager.cs`** — Coordinates which presenter renders each step.
- **`Tutorial/UI/TutorialCanvasBuilder.cs`** — Builds the Canvas at runtime (no prefab dependency).
- **`Tutorial/UI/TutorialModalPanel.cs`** — Full-screen blocking modal.
- **`Tutorial/UI/TutorialOverlayController.cs`** + **`TutorialOverlayBuilder.cs`** — Dimmer with highlighted cutout, gold border, pulsing animation, and adjacent tooltip text.
- **`Tutorial/UI/TutorialHintIndicator.cs`** — Stub for pulsing-dot mode.
### Dialogue (live)
- **`Tutorial/Dialogue/DialogueManager.cs`** — Loads all `Resources/Dialogues/*.json` scripts at startup, indexes by trigger ID, drives the panel.
- **`Tutorial/Dialogue/DialogueScript.cs`** / **`DialogueStep.cs`** — JSON-shaped records for scripts and steps.
- **`Tutorial/Dialogue/DialoguePanelController.cs`** — Renders the speaker headshot, body text, instruction line, optional highlight target, and Continue button.
- **Scripts:** `Assets/Resources/Dialogues/tutorial_strategic.json`, `tutorial_battle.json`.
---
## Step Lifecycle (Step-UI System)
1. **Triggered**`TutorialTriggerRegistry` raises an event (e.g., `game_started`, `battle_entered`).
2. **Queued or shown** — If no active sequence, show immediately. If an active step is hidden (`DisplayMode.None`), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue.
3. **Rendered**`TutorialUIManager.ShowCurrentStep()` dispatches to the modal/overlay/hint presenter based on `DisplayMode`.
4. **Awaiting completion** — One of:
- `ButtonClick` — user dismisses
- `GameEvent` — wait for a named event (e.g., `province_selected`)
- `Timer` — auto-advance after delay
- `UIInteraction` — wait for the highlighted target to be interacted with
- `Condition` — custom predicate (rare)
5. **Advance**`AdvanceStep()` moves to the next step or completes the sequence; completion writes to `TutorialState`.
Hidden steps (`DisplayMode.None`) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event.
---
## Display Modes
| Mode | Renderer | Notes |
|------|----------|-------|
| **Modal** | `TutorialModalPanel` | Full blocking dialog with dim background |
| **Overlay** | `TutorialOverlayController` | Dimmer with cutout + tooltip near a target |
| **Tooltip** | `TutorialOverlayController` | Currently same renderer as overlay |
| **Hint** | `TutorialHintIndicator` | Pulsing dot only (stub) |
| **None** | (invisible) | Waits for a completion event |
Panels can be anchored via `TutorialPanelAnchor` (`Center` / `Left` / `Right` / `Top` / `Bottom`).
---
## Trigger Catalog
All triggers are raised via `TutorialTriggerRegistry`. Today these flow to `DialogueManager.TriggerDialogue()` and are matched against the dialogue JSON; if step-UI sequences are re-registered, they will also route there. File:line citations are for the registry unless noted; line numbers may drift.
### Bootstrap / first-session
| Trigger | Fires from | When |
|---------|-----------|------|
| `game_started` | `EagleGameController.SetUpGame()` | First time entering a tutorial game |
| `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears |
| `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Battle!** |
| `tutorial_battle_ended` | `CheckTutorialBattleEnded()` ~L594 | Tutorial battle removed from running models |
| `tutorial_rebuild_support` | `CheckTutorialRebuildSupport()` ~L616 | Captured-heroes phase done |
| `tutorial_taxes_collected` | `CheckTaxesCollected()` ~L680 | New Year action with positive tax delta |
| `tutorial_in_town` | `CheckInTown()` ~L500 | Return command becomes available |
### Strategic-map contextual
| Trigger | Site | When |
|---------|------|------|
| `province_selected` | `EagleGameController.ProvinceWasSelected()` | Province click |
| `command_issued` | `EagleGameController.PostCommittedCommand()` | User commits |
| `diplomacy_available` | `CheckStrategicCommandsAvailable()` ~L243 | Diplomacy command appears |
| `weather_control_available` | `CheckStrategicCommandsAvailable()` ~L249 | Weather command appears |
| `hero_recruitment_available` | `CheckHeroRecruitmentAvailable()` ~L266 | Free heroes detected |
| `tutorial_ready_to_join` | `CheckReadyToJoinHero()` ~L479 | Free hero with `WouldJoin` |
| `profession_<X>_encountered` | `CheckProfessionTutorial()` ~L726 | First time seeing each profession |
### Strategic guidance (state-derived, conditions checked each model update)
| Trigger | Site | Condition |
|---------|------|-----------|
| `guidance_loyalty_danger` | `CheckLoyaltyDanger()` ~L339 | November + hero loyalty < 70 |
| `guidance_neighbor_danger` | `CheckNeighborDanger()` ~L383 | Hostile faction adjacent |
| `guidance_recruit_heroes` | `CheckRecruitHeroesGuidance()` ~L420 | Province with support ≥40 has free heroes |
| `guidance_expand` | `CheckExpandGuidance()` ~L449 | One stable province, hasn't expanded |
| `guidance_sworn_kinship` | `CheckSwornKinshipGuidance()` ~L549 | Good candidate or 4+ provinces |
| `tutorial_loyalty_warning` | `CheckTutorialLoyaltyWarning()` ~L634 | November + hero loyalty < 70 |
| `tutorial_support_deadline` | `CheckTutorialSupportDeadline()` ~L654 | December + province support < 40 |
### Strategic action-result triggers (fired from `OnStrategicActionResult()`)
| Trigger | Site | When |
|---------|------|------|
| `hero_stat_gained` | ~L281 | `HeroStatGained` action result |
| `hero_profession_gained` | ~L284 | `ProfessionGained` action result |
| `tutorial_faction_appears` | ~L287 | `TutorialFactionAppears` action — the Fracture Covenant landing |
| `tutorial_hero_faction_appears` | ~L290 | `TutorialHeroFactionAppears` action (no dialogue script today) |
| `tutorial_hero_departed` | ~L299 | First `HeroesDeparted` action against another player (King's hero abandons service) |
| `tutorial_hero_departed_again` | ~L302 | Second `HeroesDeparted` in a *different* month than the first |
### Tactical (battle) — abilities and spells
Combat triggers are *paced*: `_combatTutorialPending` ensures only one fires per action, with priority `archery > duel > melee > charge > fire`.
| Trigger | Site | When |
|---------|------|------|
| `shardok_placement_started` | `OnBattleEntered()` ~L796 | Battle in setup phase |
| `shardok_battle_started` | `OnBattleEntered()` ~L793 | Battle begins |
| `shardok_battle_running` | `OnTacticalCommandsAvailable()` ~L948 | Player's first turn |
| `archery_available` | `OnTacticalCommandsAvailable()` ~L990 | Archery available |
| `melee_available` | `OnTacticalCommandsAvailable()` ~L991 | Melee available |
| `duel_available` | `OnTacticalCommandsAvailable()` ~L1005 | Duel available (non-Tarn target) |
| `ability_charge_available` | `OnTacticalCommandsAvailable()` ~L1001 | Charge for Old Marek |
| `start_fire_available` | `OnTacticalCommandsAvailable()` ~L1021 | Start Fire on enemy |
| `hide_available` | `OnTacticalCommandsAvailable()` ~L985 | Hedrick can hide |
| `thunderstorm` | `OnTacticalCommandsAvailable()` ~L1056 | Weather is thunderstorm |
| `spell_lightning_available` | `OnTacticalCommandsAvailable()` ~L960 | Lightning available |
| `spell_meteor_available` | `OnTacticalCommandsAvailable()` ~L963 | Meteor available |
| `spell_holywave_available` | `OnTacticalCommandsAvailable()` ~L965 | Holy Wave available |
| `spell_raisedead_available` | `OnTacticalCommandsAvailable()` ~L969 | Raise Dead available |
| `spell_lightning_cast` / `spell_meteor_cast` / `spell_holywave_cast` / `spell_raisedead_cast` | `CheckTacticalActionType()` ~L830-838 | Spell action observed |
| `ability_charge_used` | `CheckTacticalActionType()` ~L842 | Charge attack executed |
| `terrain_fire_encountered` | `CheckTacticalActionType()` ~L864 | Fire damage / spread |
| `terrain_water_encountered` | `CheckTacticalActionType()` ~L867 | Water crossing |
| `engineer_near_enemy` | `CheckEngineerNearEnemy()` ~L1105 | John Ranil within 3 hexes of enemy |
| `tutorial_reinforcement_engineer` / `tutorial_reinforcement_paladin` | `CheckTacticalActionType()` ~L857 | Reinforcement arrival |
| `friendly_hero_captured` | `CheckHeroRemovals()` ~L919 | Friendly hero unit removed |
| `enemy_hero_captured` | `CheckHeroRemovals()` ~L928 | Enemy hero unit removed |
| `battle_action` | `ShardokGameController.OnBattleAction()` | Any action result |
| `turn_ended` | `ShardokGameController.OnTurnEnded()` ~L1137 | Battle turn ends |
| `shardok_battle_reset` | `ShardokGameController.cs:627` (fired *directly* to `DialogueManager`, bypassing the registry) | Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire |
### One-shot semantics
Most contextual triggers are guarded by booleans on the registry (e.g., a `_diplomacyShown` flag). These are session-local — they don't persist via `TutorialState`, but the registry has `ResetBattleTriggerFlags()` for replays. The strategic completion list *does* persist across sessions.
---
## Highlight Targets
Steps reference UI by string ID through `TutorialTargetRegistry`:
1. Static targets are wired in the Inspector on the registry.
2. Dynamic targets register at runtime (`RegisterTarget(id, rectTransform)`).
3. Lookup falls back to `GameObject.Find()` if not registered.
Step options:
- `TargetGameObjectPath` — primary highlight
- `AdditionalHighlightTargets[]` — multiple at once
- `HighlightBoundsFromChildren` — use children's bounding box
- `HighlightPulsing` — strobe animation
Dialogue scripts use the same registry via `highlightTarget` (and `persistHighlight: true` to keep the highlight after the dialogue closes).
---
## Sequences vs. Contextual Dispatch
- **Onboarding sequence** (`IsOnboarding = true`) — Single linear sequence, started by `StartOnboarding()`. Resumes from `OnboardingStepReached`. Counts only visible steps for progress.
- **Contextual** (`IsOnboarding = false`) — Single- or multi-step, dispatched on demand by `TriggerContextualTutorial()`.
- Dispatch rules in `TriggerContextualTutorial()`:
1. No active sequence → show immediately.
2. Active step is hidden (`None`) → interrupt.
3. Both are command tutorials → interrupt.
4. Otherwise → queue.
---
## Dialogue System (Currently Live)
`DialogueManager` is a parallel system, not a step-UI subclass. It loads every `TextAsset` in `Resources/Dialogues/` at startup, parses them as `DialogueScript` objects, and indexes by `trigger`.
Script shape (see `tutorial_strategic.json`):
```json
{
"scripts": [{
"id": "tutorial_opening",
"trigger": "game_started",
"panelPosition": null,
"steps": [{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "...",
"instructionText": "Click Battle! to enter tactical combat.",
"highlightTarget": "GoToBattleButton",
"persistHighlight": true,
"highlightProvince": "Onmaa",
"completionEvent": null
}]
}]
}
```
When a registry trigger fires, `OnGameEvent()` calls `DialogueManager.TriggerDialogue(triggerId)`. If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a `completionEvent` (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes.
Dialogue panel position defaults sensibly per scene (`top` during combat) and can be overridden per script.
---
## Persistence
`TutorialState` (PlayerPrefs JSON):
- `OnboardingCompleted` (bool)
- `OnboardingStepReached` (int)
- `CompletedTutorials` (List → HashSet at load)
- `DismissedHints` (List → HashSet at load)
- `AllTutorialsDisabled` (bool)
Reset paths:
- `TutorialState.Reset()` clears everything.
- Entering a tutorial game (`GameType.Tutorial`) resets so it can be replayed.
- Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`.
`DialogueManager` tracks completed scripts in memory only — they reset whenever `TutorialState` does.
---
## Bootstrap
1. `TutorialManager` is in the scene as a singleton; `Awake` loads state and creates the trigger registry. `RegisterAll()` is called here but currently no-ops.
2. `ConnectionHandler` sets `IsTutorialGame` and `IsMultiplayerGame` when the game is created/joined.
3. `EagleGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(this, null)`. If `IsTutorialGame && !OnboardingCompleted`, it would call `StartOnboarding()` — currently a no-op because `OnboardingSequence` is null.
4. The first model update raises `game_started`. `DialogueManager` matches `tutorial_opening` from `tutorial_strategic.json` and the dialogue runs.
5. `ShardokGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(null, this)` when battle starts; raises `battle_entered` and battle-only triggers from there.
---
## Expected Tutorial Flow (today)
Driven by `tutorial_strategic.json` + `tutorial_battle.json`. Old Marek the Learned narrates almost everything.
### Narrative arc
You play **Sadar Rakon**, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the **Reclamation**. He's been pushed back to **Onmaa** for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — **The Fracture Covenant**, led by a mysterious figure called *The Eagle* — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion."
### Strategic-map flow (`tutorial_strategic.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `game_started` | **Opening monologue** (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the **Battle!** button (`persistHighlight`) |
| 2 | `tutorial_battle_ended` | **Aftermath**: Tarn has *vanished*; you've captured his lieutenants. Recruit them or hold them |
| 3 | `tutorial_rebuild_support` | **Rebuild Onmaa** (3 steps): Marek frames it; **John Ranil** introduces *Improve* (engineer bonus); **Elena Fyar** introduces *Give Alms* (paladin bonus). Goal: 40 support by January |
| 4 | `tutorial_loyalty_warning` | November-ish, low-loyalty hero may leave at year end → use *Give Gold* / *Feast* |
| 5 | `tutorial_support_deadline` | December nag if support < 40: have Elena give alms now |
| 6 | `guidance_expand` (script `tutorial_expansion`) | Once support is stable: keep developing, taxes coming in January |
| 7 | `tutorial_taxes_collected` | January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 12 behind |
| 8 | `tutorial_ready_to_join` | A free hero in `{provinceName}` is ready to recruit → Travel + Recruit |
| 9 | `tutorial_in_town` | When you Travel: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; *Return* ends the turn |
| 10 | `tutorial_faction_appears` | **The twist**: Fracture Covenant lands at Ingia and Soria. *The Eagle* commands them. Tarn may be with them |
| 11 | `tutorial_hero_departed` | First King's hero abandons service — "something deeper is at work" |
| 12 | `tutorial_hero_departed_again` | Second one in a different month — "something is wrong, I can feel it" (foreshadowing) |
### Tactical battle flow (`tutorial_battle.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `shardok_placement_started` | **Placement** (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit |
| 2 | `shardok_battle_running` | **Turn 1 strategy** (2 steps): victory conditions for both sides, advice to hold castles and End Turn |
| 3 | `archery_available` | First archery opportunity → purple outlines, longbows vs. armor |
| 4 | `melee_available` | First melee opportunity → right-click adjacent enemy |
| 5 | `ability_charge_available` | Charge mechanics (Marek hams it up: "old scholar without a horse") |
| 6 | `start_fire_available` | Start Fire on enemy hex, fire spread mechanics |
| 7 | `thunderstorm` | Weather: archery disabled, fires extinguished |
| 8 | `tutorial_reinforcement_paladin` | **Elena Fyar arrives** mid-battle (2-step exchange) → Paladin profession intro (Holy Wave) |
| 9 | `tutorial_reinforcement_engineer` | **John Ranil arrives** (2-step exchange) → Engineer profession intro |
| 10 | `engineer_near_enemy` | Once Ranil is close to enemy: Fortify + Reduce (siege bombardment) |
| 11 | `duel_available` | Champion duel mechanics (Marek warns *not* to duel Tarn himself) |
| 12 | `hide_available` | Hedrick the Hedge-merchant: forest/swamp Hide + ambush |
| 13 | `enemy_hero_captured` / `friendly_hero_captured` | Capture mechanics, with `{heroName}` substitution |
| 14 | `shardok_battle_reset` | If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing |
Most remaining "Tutorial / first-session onboarding" items in `SMALL_EAGLE_TODO.md` (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to *adding new dialogue scripts* against existing trigger IDs, or — if the step-UI is revived — to populating `TutorialContentDefinitions.RegisterAll()` and removing the early return.
---
## Notable Behaviors and Gotchas
- **`IsTutorialGame` gates everything.** Outside tutorial games, both subsystems short-circuit early.
- **Combat tutorials are deliberately paced.** Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them.
- **One-shot flags are session-local.** A trigger that "already fired" will not re-fire even if `TutorialState` is reset, until the registry is recreated.
- **`TutorialTargetRegistry` lookups silently fall back to `GameObject.Find`.** Typos in target IDs will render with no highlight rather than throwing.
- **`TutorialContentDefinitions.RegisterAll()` returning early is load-bearing.** If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger.
- **`Tutorial/TUTORIAL_PLAN.md`** in the Unity project is an older implementation plan and partially overlaps this doc.
+173
View File
@@ -0,0 +1,173 @@
# URP Migration Plan
## Why
Unity is removing Built-In Render Pipeline (BiRP) support after Unity 6.7. We're currently on Unity 6.4. Each Unity minor version ships roughly quarterly, giving us a few release cycles before BiRP is dropped. This document lays out the migration plan.
## Strategy: Long-Lived Feature Branch
All URP conversion work happens on a **long-lived `urp-migration` branch**. Main stays fully functional on BiRP throughout the migration.
- **Main branch**: Continues using BiRP. All gameplay, AI, and server development proceeds normally.
- **URP branch**: Accumulates rendering pipeline changes across all phases.
- **Regular rebasing**: Periodically rebase/merge `main` into the URP branch to stay current. Shader and material changes rarely conflict with gameplay code, so merge conflicts should be manageable.
- **Merge to main**: Only when everything renders correctly and visual QA passes.
This avoids the "everything is pink" problem of switching the pipeline on main before shaders are converted.
## Current State
| Category | Count | Notes |
|---|---|---|
| Shaders | 47 | 7 custom Eagle, 1 hex mesh, 20 Polytope, 13 TextMesh Pro, 6 other third-party |
| Materials | 325 | Mix of Standard shader, custom, and third-party |
| ShaderGraph files | 4 | All TextMesh Pro (URP + HDRP variants already exist) |
| Scenes with baked lighting | 6 | Gameplay, Connection, Eagle, Shardok, Shared, Map Editor |
| Post-processing | 1 profile | PPP_Orc.asset (Post Processing Stack v2, FXAA/TAA, AO) |
| C# rendering scripts | 16 | Material/shader property manipulation only |
| Rendering path | Forward | Matches URP default |
### Positive Findings
- **No OnRenderImage, Graphics.Blit, CommandBuffer, or GL.\* usage** in C# code
- Forward rendering already in use (matches URP default)
- TextMesh Pro already has URP ShaderGraph variants in the project
- No custom render passes or ScriptableRenderFeatures
- Shader property manipulation (SetTexture, SetFloat, SetColor) is URP-compatible
### Known Risk Areas
- **GrabPass shaders** (HeatShimmerShader, PT_Water_Shader) have no direct URP equivalent
- **ProvinceMapShader** is complex (province ID lookup, border rendering, ocean animation, faction highlighting, UI clipping)
- **Surface shaders** (`#pragma surface surf Standard`) must be rewritten as HLSL or ShaderGraph
- **Polytope Studio shaders** (20 shaders) have no vendor-provided URP variants
- **Post Processing Stack v2** must be replaced with URP's integrated Volume system
- **Tessellation** (PT_Water_Shader) is not natively supported in URP
## Shader Inventory
### Custom Eagle Shaders (7) -- HIGH PRIORITY
| Shader | Complexity | Key Issues |
|---|---|---|
| ProvinceMapShader | High | Province ID texture lookup, border rendering, ocean animation, faction highlighting, `UnityUI.cginc` dependency, stencil/clipping |
| ProvinceWeatherMapShader | Medium | Weather overlay rendering |
| ProvinceWeatherShader | Medium | Province weather effects |
| ProvinceParticleShader | Low | Custom particle rendering |
| HeatShimmerShader | High | **GrabPass** for screen distortion, province masking |
| ClipRectParticleUnlit | Low | UI-clipped particle shader |
| maskShader | Low | UI masking |
### Hex Mesh Shader (1) -- MODERATE PRIORITY
Uses `#pragma surface surf Standard` with GPU instancing. Straightforward conversion to URP Lit or ShaderGraph.
### Third-Party Shaders (39)
| Source | Count | Complexity | Notes |
|---|---|---|---|
| Polytope Studio | 20 | Moderate-High | PBR, Toon, vegetation (custom lighting), water (**GrabPass + tessellation**). No vendor URP pack available. |
| TextMesh Pro | 13 | Low | URP ShaderGraph variants already exist in project |
| RRFreelance | 3 | Low | Standard surface shaders, direct conversion |
| Clown.fat | 2 | Moderate | Custom ToonRamp lighting model |
| GUI Pro Kit | 1 | Low | Hidden particle shader |
### GrabPass Shaders (Require Special Handling)
GrabPass does not exist in URP. These must be reimplemented using `ScriptableRenderPass` + `Renderer Features` or Blit-based alternatives:
1. **HeatShimmerShader** -- Screen distortion effect (currently has a shimmer-disabled TODO, may be deprioritized)
2. **PT_Water_Shader** -- Water refraction/transparency (PT_Water_Shader_WebGl exists without GrabPass as a reference)
## Phased Approach
### Phase 1: Pipeline Setup + Auto-Conversion (Days 1-3)
- Install URP package
- Create URP Pipeline Asset and Renderer Asset
- Configure basic pipeline settings (forward rendering, shadow settings)
- Run Unity's **Render Pipeline Converter** (Edit > Rendering > Render Pipeline Converter)
- Auto-converts Standard shader materials and some built-in shaders
- Handles a significant portion of the 325 materials
- Will NOT touch custom shaders
- Create a test scene to validate basic rendering
- Migrate viewport clipping system (`RendererViewportClipper`, `ViewportClipper`, `PopupClipper`) to use URP-compatible global shader properties
### Phase 2: Custom Eagle Shaders (Weeks 1-3)
This is the critical path. Without these, the game is unplayable.
**Week 1-2: ProvinceMapShader**
- Convert from BiRP Cg/HLSL to URP HLSL
- Replace `UnityCG.cginc` includes with `Core.hlsl` / `Common.hlsl`
- Replace `UnityUI.cginc` with custom URP-compatible UI clipping
- Preserve province ID texture lookup, border rendering, ocean animation, faction highlighting
- Validate stencil operations and render queue ordering
**Week 2-3: Remaining Eagle shaders**
- ProvinceWeatherMapShader + ProvinceWeatherShader
- ProvinceParticleShader + ClipRectParticleUnlit
- maskShader
- Hex Mesh Shader (convert surface shader to URP Lit)
- HeatShimmerShader (reimplement without GrabPass, or defer if shimmer remains disabled)
### Phase 3: Third-Party Shaders (Weeks 4-6)
**Polytope Studio (20 shaders)**
- Convert PBR shaders (Armors, NPC, Weapons, Props, Rock) from surface shaders to URP Lit / ShaderGraph
- Convert Toon shaders to custom URP shader or ShaderGraph with custom lighting
- Convert vegetation shaders (custom `StandardCustom` lighting) to ShaderGraph
- PT_Water_Shader: Reimplement without GrabPass and tessellation (use PT_Water_Shader_WebGl as reference for non-GrabPass approach)
**Other third-party**
- Clown.fat ToonRamp shaders: Convert custom lighting model to ShaderGraph
- RRFreelance shaders: Direct Standard-to-URP-Lit conversion
- GUI Pro Kit particle shader: Convert to URP particle shader
### Phase 4: Materials, Lighting & Post-Processing (Weeks 7-8)
**Materials**
- Batch-update any remaining materials not handled by the auto-converter
- Verify all 325 materials render correctly
- Fix any visual differences from lighting model changes
**Lighting**
- Rebake lightmaps for all 6 scenes (Gameplay, Connection, Eagle, Shardok, Shared, Map Editor)
- Configure URP shadow cascade settings to match current quality levels
- Verify HDR rendering
**Post-Processing**
- Remove Post Processing Stack v2 dependency (`com.unity.postprocessing: 3.5.1`)
- Replace with URP integrated Volume system
- Recreate PPP_Orc effects (FXAA/TAA, Ambient Occlusion) using URP Volume overrides
### Phase 5: Testing & Validation (Weeks 9-11)
- Visual QA across all 6 scenes
- Verify beast/character rendering (vertex colors, animations)
- Verify weather effects (blizzard, drought, flood)
- Verify particle systems (fire & explosion effects, UI particles)
- Verify UI rendering (GUI Pro Kit, Modern UI Pack, TextMesh Pro)
- Performance profiling (URP has different performance characteristics)
- Test on target platforms
- Bug fixes and visual polish
## Effort Estimates
| Phase | Best Case | Realistic | Worst Case |
|---|---|---|---|
| 1. Pipeline Setup | 1-2 days | 2-3 days | 3-5 days |
| 2. Custom Eagle Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
| 3. Third-Party Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
| 4. Materials & Lighting | 3-5 days | 1-1.5 weeks | 2 weeks |
| 5. Testing & Validation | 1 week | 1.5-2 weeks | 2-3 weeks |
| **Total** | **6-8 weeks** | **8-12 weeks** | **12-16 weeks** |
The biggest variable is Polytope Studio shader conversion. If vendor URP packs become available, Phase 3 shrinks significantly.
## Key Reminders
- **Don't leave GrabPass rewrites and ProvinceMapShader for the end** -- they're the riskiest pieces and should be tackled early.
- The migration window (~1 year) is comfortable. Spreading the work across this window is fine, but front-load the hard shader work.
- C# code changes should be minimal -- the codebase avoids direct rendering API usage.
- `UnityUI.cginc` has no direct URP equivalent. Custom implementation will be needed for UI clipping in shaders.
+111 -53
View File
@@ -8,13 +8,25 @@ The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is
## Quests the AI Actively Completes
The AI processes quest handlers in priority order. The first handler that produces a valid command wins.
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `BetrayAllyQuest` | `BetrayAllyQuestCommandChooser` | Break alliance with target faction. Only if factions don't share a border. Sends weakest non-leader hero. |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
| `TotalDevelopmentQuest` | `TotalDevelopmentQuestCommandChooser` | Improve the lowest stat in the target province |
### Resource Giving Quests
@@ -24,72 +36,96 @@ The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
| `SpendOnFeastsQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts |
| `SendSuppliesQuest` | `SendSuppliesQuestCommandChooser` | Send food to the target province |
### Province Development Quests
### Prisoner Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
| `ReleasePrisonerQuest` | `ReleasePrisonerQuestCommandChooser` | Release a specific prisoner |
| `ExilePrisonerQuest` | `ExilePrisonerQuestCommandChooser` | Exile a specific prisoner |
| `ExecutePrisonerQuest` | `ExecutePrisonerQuestCommandChooser` | Execute a specific prisoner |
| `ReturnPrisonerQuest` | `ReturnPrisonerQuestCommandChooser` | Return a prisoner to their faction |
| `ReleaseAllPrisonersQuest` | `ReleaseAllPrisonersQuestCommandChooser` | Release all prisoners |
### Province Order Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `DevelopProvincesQuest` | `DevelopProvincesQuestCommandChooser` | Switches provinces to Develop order. Prefers provinces without hostile neighbors. |
| `MobilizeProvincesQuest` | `MobilizeProvincesQuestCommandChooser` | Switches provinces to Mobilize order. Prefers provinces with hostile neighbors. Skipped if a DevelopProvincesQuest also exists (conflict avoidance). |
### Weather/Epidemic Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `StartEpidemicQuest` | `StartEpidemicQuestCommandChooser` | Start an epidemic in a province. Skipped if the target province belongs to the acting faction. |
| `StartBlizzardQuest` | `ControlWeatherQuestCommandChooser` | Start a blizzard via ControlWeather command. Skipped if the target province belongs to the acting faction. |
| `StartDroughtQuest` | `ControlWeatherQuestCommandChooser` | Start a drought via ControlWeather command. Skipped if the target province belongs to the acting faction. |
### Military Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `GrandArmyQuest` | `GrandArmyQuestCommandChooser` | Uses OrganizeTroops to top off existing battalions and hire Light Infantry. Only executes if resources are sufficient to fully meet the troop target. |
| `BattalionDiversityQuest` | `BattalionDiversityQuestCommandChooser` | Hires one battalion of a missing type to reach 3+ distinct types. Only attempts if the province already has 2+ existing types, has sufficient development (`meetsRequirements`), and gold/food surplus. |
| `UpgradeBattalionQuest` | `UpgradeBattalionQuestCommandChooser` | Walks a priority tree per turn: arm-completes (with optional travel and/or food sale setup) → train-completes → march in a pre-qualified neighbor battalion → organize/top-off of the target type → march in an unqualified neighbor battalion → develop Economy/Agriculture → train progress → develop Infrastructure → arm progress. Only toggles Travel when it directly enables an arm-completes finisher. |
### Riot Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. Not a quest command chooser — modifies existing riot handling priority. |
### Reconnaissance Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ReconSpecificProvincesQuest` | `ReconSpecificProvincesQuestCommandChooser` | Recon specific target provinces. Prefers not-yet-reconned targets. |
| `ReconProvincesQuest` | `ReconProvincesQuestCommandChooser` | Issues a Recon command to reconnoiter a province |
### Beast Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `FightBeastsAloneQuest` | `FightBeastsAloneQuestCommandChooser` | Send an expendable non-leader hero to fight beasts without a battalion. Hero must have power at most `MaxExpendableHeroPowerRatio` (default 0.75) times the quest-giving hero's power. Picks the weakest eligible hero. |
### Special Event Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ApprehendOutlawQuest` | `ApprehendOutlawQuestCommandChooser` | Apprehend a specific outlaw hero when present in the province |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `RestProvinceQuest` | `RestProvinceQuestCommandChooser` | Use the Rest command in a specific province |
| `SwearBrotherhoodWithHeroQuest` | `SwearBrotherhoodQuestCommandChooser` | Swear brotherhood with a specific hero |
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
## Quests the AI Does Not Yet Attempt to Complete
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
### Planned for Implementation
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
None currently planned.
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
### Not Planned
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
These quests are too situational, passive, or risky to actively pursue. They may be completed naturally through gameplay.
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
- `BorderSecurityQuest` - Have troops in a border province. Involves taking provinces; better left to organic expansion.
- `DefeatFactionQuest` - Defeat a specific faction. Too risky to pursue proactively.
- `SpecificExpansionQuest` - Conquer a specific province. Too risky.
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces. Too risky.
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered. Can't reliably engineer.
- `WinBattlesQuest` - Win a number of battles. Passive.
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader. Complex multi-step.
- `WealthQuest` - Accumulate gold and food. Happens passively.
- `RepairDevastationQuest` - Repair devastation. Happens passively.
## Implementation Details
@@ -102,12 +138,34 @@ Each chooser extends either:
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
1. TruceWithFaction
2. Improve (Agriculture/Economy/Infrastructure)
3. TotalDevelopment
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
10. ReleasePrisoner
11. ExilePrisoner
12. ExecutePrisoner
13. ReturnPrisoner
14. ReleaseAllPrisoners
15. ApprehendOutlaw
16. SpendOnFeasts
17. RestProvince
18. DevelopProvinces
19. MobilizeProvinces
20. SendSupplies
21. StartEpidemic
22. ControlWeather (StartBlizzard/StartDrought)
23. GrandArmy
24. FightBeastsAlone
25. BattalionDiversity
26. UpgradeBattalion
27. ReconSpecificProvinces
28. ReconProvinces
29. SwearBrotherhood
30. Alliance
31. BetrayAlly
@@ -423,6 +423,35 @@ bool AICommandFilter::IsWastefulAction(
break;
}
case CommandType::FEAR_COMMAND: {
// Solo attackers gain nothing from Fear: the target is stunned for a round but
// there is no teammate to capitalize, and the caster ends its turn no closer to
// killing units or capturing castles. Defenders, by contrast, benefit from stalling,
// so this restriction only applies to the attacking side.
//
// Exception: if the victim is standing on a fire tile, stunning locks them in place
// while the fire modifier keeps chewing through their unit value — the burn does the
// work that a follow-up attacker would normally provide.
if (isDefender) { break; }
if (CountPlayerUnits(gameState, pid) > 1) { break; }
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"FEAR_COMMAND missing required target information");
}
const Coords victimLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto* victimTerrain = GetTerrain(gameState->hex_map(), victimLocation);
if (!victimTerrain->modifier().fire().present()) {
return true; // Solo attacker fear with no fire-follow-up is a wasted action
}
break;
}
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
const int targetRow = cmd.GetTargetRow();
@@ -177,6 +177,8 @@ void ShardokGameController::DoAIThread() {
engine->PostCommand(playerId, results.chosenIndex);
LockedNotifyClients();
engine->PostWhileCurrentPlayerHasOnlyOneOption(nullptr);
LockedNotifyClients();
aiThreadKeepGoing = !engine->GameIsOver();
}
} catch (const std::exception &e) {
@@ -340,6 +342,21 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
updates.filteredResults.emplace_back(fid, actionResultViews, acs);
}
// Per-watcher filtered views. A watcher is a non-participant Eagle
// faction allied to one or more participants — they get a view that
// treats their allied participants' units as fully visible (hero
// stats, hidden-from-opponents actions like meteor casts, etc.).
// Participants whose allies list named this watcher are passed in as
// alliedPids so UnitFilter / ActionResultFilter reveal those units to
// the watcher.
for (const auto &[watcherFid, allyPids] : watcherAllies) {
// Watchers can't post commands, so availableCommands is null.
updates.filteredResults.emplace_back(
watcherFid,
engine->FilterNewResultsForWatcher(-1, allyPids, startingActionId),
nullptr);
}
updates.filteredResults.emplace_back(
-1,
engine->FilterNewResults(-1, startingActionId),
@@ -347,6 +364,25 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
auto gameStateBytes = engine->GetCurrentGameStateBytes();
updates.currentGameState.swap(gameStateBytes);
// Extract battle progress data from the FlatBuffer game state
const auto *gs = engine->GetCurrentGameState().Get();
updates.currentRound = gs->current_round();
for (const auto *player : *gs->player_infos()) {
int32_t troopCount = 0;
for (const auto *unit : *gs->units()) {
if (unit->player_id() != player->player_id()) continue;
switch (unit->status()) {
case net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
troopCount += unit->battalion().size();
break;
default: break;
}
}
updates.playerTroopCounts.emplace_back(player->player_id(), troopCount);
}
}
return updates;
@@ -445,7 +481,9 @@ auto ShardokGameController::WaitForUpdatesAndPush(
updates.mainResults,
updates.filteredResults,
updates.newUnfilteredCount,
updates.currentGameState);
updates.currentGameState,
updates.currentRound,
updates.playerTroopCounts);
}
// Tutorial battle reset: defender lost, signal reset instead of game over
@@ -73,7 +73,9 @@ public:
const vector<ActionResult>& mainResults,
const vector<OnePlayerUpdates>& filteredResults,
int32_t newUnfilteredCount,
const byte_vector& currentGameState) = 0;
const byte_vector& currentGameState,
int32_t currentRound,
const vector<std::pair<int32_t, int32_t>>& playerTroopCounts) = 0;
/// Called when the game ends
virtual void OnGameOver(const GameOverInfo& info) = 0;
@@ -118,6 +120,11 @@ private:
vector<shared_ptr<ShardokAIClient>> aiClients;
std::thread aiThread;
// Each entry maps an Eagle faction id of a non-participant watcher to the
// Shardok pids of participants whose allies list named this watcher. Used
// when generating per-watcher filtered views in GetUpdates.
const vector<std::pair<int32_t, vector<PlayerId>>> watcherAllies;
static auto MakeLogFilePath() -> string {
const time_t timer = time(nullptr);
char buf[255];
@@ -137,14 +144,16 @@ public:
ShardokGameController(
unique_ptr<ShardokEngine> e,
string mapName,
string serializedRequest = "")
string serializedRequest = "",
vector<std::pair<int32_t, vector<PlayerId>>> watcherAllies = {})
: serializedRequest(std::move(serializedRequest)),
engine(std::move(e)),
cachedGameId(engine->GetGameId()),
mapName(std::move(mapName)),
logFilePath(MakeLogFilePath()),
aiClients(MakeAIClients(engine)),
aiThread(&ShardokGameController::DoAIThread, this) {
aiThread(&ShardokGameController::DoAIThread, this),
watcherAllies(std::move(watcherAllies)) {
std::unique_lock lk(masterLock);
aiCondition.notify_one();
}
@@ -171,6 +180,8 @@ public:
vector<OnePlayerUpdates> filteredResults;
int32_t newUnfilteredCount;
byte_vector currentGameState;
int32_t currentRound = 0;
vector<std::pair<int32_t, int32_t>> playerTroopCounts; // (playerId, troopCount)
};
auto GetUpdates(int64_t startingActionId) -> AllUpdates;
auto GetCurrentGameStateBytes() -> byte_vector;
@@ -217,6 +217,58 @@ auto ShardokEngine::GetGameStateView(const PlayerId askingPlayer) const
return filteredHistory;
}
[[nodiscard]] auto ShardokEngine::FilterNewResultsForWatcher(
const PlayerId askingPlayer,
const vector<PlayerId> &alliedPids,
const int64_t previousActionCount) const
-> vector<net::eagle0::shardok::api::ActionResultView> {
if (previousActionCount < startingHistoryCount) {
throw ShardokInternalErrorException("Unable to fetch history before startingHistoryCount");
}
vector<ShardokActionWithResultingState> unfilteredHistory = GetGameHistory(previousActionCount);
vector<net::eagle0::shardok::api::ActionResultView> filteredHistory{};
GameStateW startingState = (previousActionCount == 0)
? GameStateW{}
: GetGameStateAtStartOfAction(previousActionCount);
GameStateView startingView = (previousActionCount == 0) ? GameStateView{}
: GameStateFilteredForPlayerWithAllies(
settingsGetter,
startingState,
askingPlayer,
alliedPids);
GameStateW &previousState = startingState;
GameState *previousStatePtr = nullptr;
GameStateView &previousView = startingView;
for (const ShardokActionWithResultingState &awrs : unfilteredHistory) {
GameStateView viewAfter = GameStateFilteredForPlayerWithAllies(
settingsGetter,
GameStateW::FromByteString(awrs.state_after_fb()),
askingPlayer,
alliedPids);
if (auto filteredResult = ActionResultFilteredForPlayerWithAllies(
previousStatePtr,
previousView,
viewAfter,
settingsGetter,
awrs.action_result(),
askingPlayer,
alliedPids);
filteredResult.has_value()) {
filteredHistory.push_back(*filteredResult);
}
previousState = GameStateW::FromByteString(awrs.state_after_fb());
previousStatePtr = previousState.Get();
previousView = viewAfter;
}
return filteredHistory;
}
auto ShardokEngine::GetFilteredGameHistory(const PlayerId askingPlayer) const
-> vector<net::eagle0::shardok::api::ActionResultView> {
return FilterNewResults(askingPlayer, 0);
@@ -152,6 +152,16 @@ public:
[[nodiscard]] auto FilterNewResults(PlayerId askingPlayer, int64_t previousActionCount) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
// Variant for non-participant watchers: askingPlayer is a sentinel (not in
// player_infos) and alliedPids names the Shardok pids whose units the
// watcher should see as "self/ally" — fully visible, including hero stats
// and hidden actions.
[[nodiscard]] auto FilterNewResultsForWatcher(
PlayerId askingPlayer,
const vector<PlayerId> &alliedPids,
int64_t previousActionCount) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
[[nodiscard]] auto GetFilteredGameHistory(PlayerId askingPlayer) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
@@ -91,12 +91,10 @@ void ArcheryCommandFactory::AddAvailableArcheryCommands(
const int windDir = (int)weather->wind().direction();
for (const Coords &farCoords : TilesWithExactDistance(hexMap, position, 3)) {
auto dirTo = DirectionsTo(hexMap, position, farCoords);
int relMain = ((int)dirTo.main - windDir + 6) % 6;
bool inCone = (relMain == 0 || relMain == 1 || relMain == 5);
if (!inCone && dirTo.usesSecondary) {
int relSec = ((int)dirTo.secondary - windDir + 6) % 6;
inCone = (relSec == 0 || relSec == 1 || relSec == 5);
}
// 60° cone: tile must be in the wind direction's sector, or on its
// boundary (where the secondary direction matches the wind).
bool inCone = ((int)dirTo.main == windDir);
if (!inCone && dirTo.usesSecondary) { inCone = ((int)dirTo.secondary == windDir); }
if (inCone && !HexMapUtils::LineIsBlockedByMountains(hexMap, position, farCoords)) {
targetCoords.Add(farCoords);
}
@@ -30,6 +30,7 @@ auto ControlCommand::InternalExecute(
result.set_type(ActionType::CONTROLLED);
result.mutable_player()->set_value(GetPlayerId());
result.mutable_actor()->set_value(actorId);
result.mutable_target_unit()->set_value(targetId);
AddChangedUnit(result, actorAfter);
return {result};
@@ -4,6 +4,9 @@
#include "ActionResultFilter.hpp"
#include <algorithm>
#include <ranges>
#include "src/main/cpp/net/eagle0/shardok/library/unit/Unit.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateViewDiffer.hpp"
@@ -60,6 +63,24 @@ auto TargetIsHiddenFromOpponents(const ActionResultType type) -> bool {
const SettingsGetter &settings,
const ActionResult &result,
const PlayerId askingPlayer) -> std::optional<ActionResultView> {
return ActionResultFilteredForPlayerWithAllies(
beforeState,
beforeView,
afterView,
settings,
result,
askingPlayer,
std::vector<PlayerId>{});
}
[[nodiscard]] auto ActionResultFilteredForPlayerWithAllies(
const GameState *beforeState,
const net::eagle0::shardok::api::GameStateView &beforeView,
const net::eagle0::shardok::api::GameStateView &afterView,
const SettingsGetter &settings,
const ActionResult &result,
const PlayerId askingPlayer,
const std::vector<PlayerId> &alliedPids) -> std::optional<ActionResultView> {
double opponentKnowledgeOfActor = 0.0;
bool hiddenActor = false;
bool wasHidden = false;
@@ -76,13 +97,16 @@ auto TargetIsHiddenFromOpponents(const ActionResultType type) -> bool {
beforeState->units()->size() > static_cast<unsigned int>(actorId) &&
beforeState->units()->Get(result.actor().value())->hidden();
}
const PlayerId actorPlayer = result.has_player() ? result.player().value() : -1;
const bool actorIsSelfOrAlly =
askingPlayer == actorPlayer || std::ranges::contains(alliedPids, actorPlayer);
if (IsHiddenFromOpponents(
settings.Backing().minimum_knowledge_for_profession(),
result.type(),
opponentKnowledgeOfActor,
hiddenActor,
wasHidden) &&
askingPlayer != result.player().value()) {
!actorIsSelfOrAlly) {
return std::optional<ActionResultView>();
}
@@ -107,8 +131,7 @@ auto TargetIsHiddenFromOpponents(const ActionResultType type) -> bool {
}
if (result.has_target_coords() &&
((result.has_player() && result.player().value() == askingPlayer) ||
!TargetIsHiddenFromOpponents(result.type()))) {
(actorIsSelfOrAlly || !TargetIsHiddenFromOpponents(result.type()))) {
*resultView.mutable_target_coords() = result.target_coords();
} else {
resultView.clear_target_coords();
@@ -6,6 +6,7 @@
#define EAGLE0_ACTIONRESULTFILTER_HPP
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
@@ -25,6 +26,19 @@ using GameState = net::eagle0::shardok::storage::fb::GameState;
const net::eagle0::shardok::storage::ActionResult &result,
PlayerId askingPlayer) -> std::optional<net::eagle0::shardok::api::ActionResultView>;
// Variant that takes the asker's allied Shardok pids explicitly. Allied actor
// actions (like hidden meteor casts) are revealed to the asker as if the
// asker were the actor's owner.
[[nodiscard]] auto ActionResultFilteredForPlayerWithAllies(
const GameState *beforeState,
const net::eagle0::shardok::api::GameStateView &beforeView,
const net::eagle0::shardok::api::GameStateView &afterView,
const SettingsGetter &settings,
const net::eagle0::shardok::storage::ActionResult &result,
PlayerId askingPlayer,
const std::vector<PlayerId> &alliedPids)
-> std::optional<net::eagle0::shardok::api::ActionResultView>;
} // namespace shardok
#endif // EAGLE0_ACTIONRESULTFILTER_HPP
@@ -19,6 +19,18 @@ auto GameStateFilteredForPlayer(
const SettingsGetter& settings,
const GameState* gameState,
const PlayerId askingPlayer) -> GameStateView {
return GameStateFilteredForPlayerWithAllies(
settings,
gameState,
askingPlayer,
AlliedPids(gameState, askingPlayer));
}
auto GameStateFilteredForPlayerWithAllies(
const SettingsGetter& settings,
const GameState* gameState,
const PlayerId askingPlayer,
const std::vector<PlayerId>& alliedPids) -> GameStateView {
GameStateView gsv{};
gsv.set_asking_player(askingPlayer);
@@ -30,8 +42,6 @@ auto GameStateFilteredForPlayer(
*gsv.add_player_infos() = fb::ToPlayerInfoProto(info);
}
const auto& alliedPids = AlliedPids(gameState, askingPlayer);
for (const auto& unit : *gameState->units()) {
switch (unit->status()) {
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
@@ -5,6 +5,8 @@
#ifndef EAGLE0_GAMESTATEFILTER_HPP
#define EAGLE0_GAMESTATEFILTER_HPP
#include <vector>
#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/game_state.hpp"
@@ -19,6 +21,16 @@ using GameState = net::eagle0::shardok::storage::fb::GameState;
const GameState* gameState,
PlayerId askingPlayer) -> net::eagle0::shardok::api::GameStateView;
// Variant that takes the asker's allied Shardok pids explicitly. Used for
// non-participant watchers who have no entry in the GameState's player_infos
// but should still see units owned by their participating allies as if those
// units were their own.
[[nodiscard]] auto GameStateFilteredForPlayerWithAllies(
const SettingsGetter& settings,
const GameState* gameState,
PlayerId askingPlayer,
const std::vector<PlayerId>& alliedPids) -> net::eagle0::shardok::api::GameStateView;
} // namespace shardok
#endif // EAGLE0_GAMESTATEFILTER_HPP
@@ -196,6 +196,23 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
string unitsString;
for (const auto &hid : heroIds) { unitsString += std::to_string(hid) + " "; }
// Build the watcher → allied-Shardok-pids mapping. A non-participant
// faction "watches" via any participant that lists it as an ally.
std::vector<std::pair<int32_t, std::vector<PlayerId>>> watcherAllies;
watcherAllies.reserve(request.watcher_eagle_faction_ids_size());
for (const int32_t watcherFid : request.watcher_eagle_faction_ids()) {
std::vector<PlayerId> allyPids;
for (int i = 0; i < request.user_infos_size(); i++) {
for (const auto &ally : request.user_infos(i).allies()) {
if (ally.eagle_faction_id() == watcherFid) {
allyPids.push_back(static_cast<PlayerId>(i));
break;
}
}
}
if (!allyPids.empty()) { watcherAllies.emplace_back(watcherFid, std::move(allyPids)); }
}
printf("Starting game %s with units %s\n", gameId.c_str(), unitsString.c_str());
gamesManager->UnlockedCreateSpecifiedGame(
gameId,
@@ -204,7 +221,8 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
customMapBytes,
month,
request.tutorial_battle_config(),
request.SerializeAsString());
request.SerializeAsString(),
watcherAllies);
}
auto EagleInterfaceImpl::PostCommand(
@@ -333,6 +351,13 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
*response->mutable_game_update_response()->mutable_current_game_state() =
string(controller->GetCurrentGameStateBytes());
response->mutable_game_update_response()->set_current_round(results.currentRound);
for (const auto &[playerId, troopCount] : results.playerTroopCounts) {
auto *ptc = response->mutable_game_update_response()->add_player_troop_counts();
ptc->set_player_id(playerId);
ptc->set_troop_count(troopCount);
}
}
}
@@ -424,7 +449,9 @@ auto EagleInterfaceImpl::SubscribeToGame(
const vector<ActionResult> &mainResults,
const vector<OnePlayerUpdates> &filteredResults,
int32_t newUnfilteredCount,
const byte_vector &currentGameState) override {
const byte_vector &currentGameState,
int32_t currentRound,
const vector<std::pair<int32_t, int32_t>> &playerTroopCounts) override {
if (!active_) return;
GameStatusResponse response;
@@ -452,6 +479,13 @@ auto EagleInterfaceImpl::SubscribeToGame(
*response.mutable_game_update_response()->mutable_current_game_state() =
std::string(currentGameState.begin(), currentGameState.end());
response.mutable_game_update_response()->set_current_round(currentRound);
for (const auto &[playerId, troopCount] : playerTroopCounts) {
auto *ptc = response.mutable_game_update_response()->add_player_troop_counts();
ptc->set_player_id(playerId);
ptc->set_troop_count(troopCount);
}
if (!writer_->Write(response)) { active_ = false; }
}
@@ -32,7 +32,9 @@ auto SetUpController(
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerSetupInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController>;
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies = {})
-> shared_ptr<ShardokGameController>;
ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSettings) {
gameSettings = SettingsLoader::LoadSettings();
@@ -85,7 +87,8 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
const string &customMapBytes,
const int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId {
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies) -> GameId {
// Check for existing game with this ID and early-exit
std::shared_lock<std::shared_mutex> lk(runningControllersLock);
const auto existingGame = runningControllers.find(gameId);
@@ -120,7 +123,8 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
units,
playerInfos,
tutorialConfig,
serializedRequest);
serializedRequest,
watcherAllies);
return LockedCreateGame(controller, playerSetupInfos, serializedRequest);
}
@@ -147,7 +151,9 @@ auto SetUpController(
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController> {
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies)
-> shared_ptr<ShardokGameController> {
auto unplacedUnits = std::vector<Unit>();
UnitId nextUnitId = 0;
@@ -220,7 +226,11 @@ auto SetUpController(
engine->SetTutorialBattleConfig(tutorialConfig);
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
return std::make_shared<ShardokGameController>(
std::move(engine),
mapName,
serializedRequest,
watcherAllies);
}
} // namespace shardok
@@ -110,7 +110,9 @@ public:
const string &customMapBytes,
int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId;
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies = {})
-> GameId;
auto UnlockedCreateFromGameStateBytes(
const byte_vector &gameStateBytes,
@@ -36,7 +36,12 @@ ModelImporter:
- first:
74: -3419257869308726280
second: walk
externalObjects: {}
externalObjects:
- first:
type: UnityEngine:Material
assembly: UnityEngine.CoreModule
name: Smilodon.001
second: {fileID: 2100000, guid: 3eac01f037701614d931178d43b43f43, type: 2}
materials:
materialImportMode: 1
materialName: 0
@@ -36,7 +36,12 @@ ModelImporter:
- first:
74: -3419257869308726280
second: walk
externalObjects: {}
externalObjects:
- first:
type: UnityEngine:Material
assembly: UnityEngine.CoreModule
name: Lion
second: {fileID: 2100000, guid: ad3b6d598b37af14ab1837e95886e807, type: 2}
materials:
materialImportMode: 1
materialName: 0
@@ -36,7 +36,12 @@ ModelImporter:
- first:
74: -3419257869308726280
second: walk
externalObjects: {}
externalObjects:
- first:
type: UnityEngine:Material
assembly: UnityEngine.CoreModule
name: Lioness
second: {fileID: 2100000, guid: ce62ac8db8ed065468ad2b25bf8ceb76, type: 2}
materials:
materialImportMode: 1
materialName: 0
@@ -24,7 +24,12 @@ ModelImporter:
- first:
74: -3419257869308726280
second: walk
externalObjects: {}
externalObjects:
- first:
type: UnityEngine:Material
assembly: UnityEngine.CoreModule
name: rhinocerosMateriale
second: {fileID: 2100000, guid: bae71c57f6d69494b9ca14349944d9ad, type: 2}
materials:
materialImportMode: 1
materialName: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -110,7 +110,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -139,7 +139,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -109,7 +109,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -109,7 +109,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -109,7 +109,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -109,7 +109,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -109,7 +109,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -109,7 +109,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -126,7 +126,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -121,7 +121,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -121,7 +121,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -121,7 +121,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -121,7 +121,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -107,7 +107,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
@@ -112,7 +112,7 @@ ModelImporter:
importMaterials: 1
materialName: 0
materialSearch: 1
materialLocation: 0
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0

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