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>
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>
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>
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>
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>
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>
* 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>
* 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>
* 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>
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>
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>
* 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>
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>
* 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>
* 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>
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>
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>
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>
* 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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
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>
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>
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>
* 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>
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>
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>
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>
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>
* 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.
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.
* 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>
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>
* 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>
* 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>
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>
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>
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>
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>
* 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>
* 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>