Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.6 6762be71b4 Add setup-node step before JS syntax check
The self-hosted runner doesn't have Node.js in PATH.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:19:13 -08:00
adminandClaude Opus 4.6 401dbb0ce0 Add JS syntax checking to CI and merge conflict pre-commit hook
A git conflict marker was accidentally left in map_editor.js after a
rebase merge, breaking the map editor in production. This adds two
safeguards:

- CI lint job: `node --check` to catch JS syntax errors
- Pre-commit: `check-merge-conflict` hook to catch conflict markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:05:35 -08:00
e7d76d99b7 Add surviving reinforcement units to province after battle (#6213)
When defending reinforcement units (from tutorial) survive a battle,
add them to the defending province's rulingFactionHeroIds and
battalionIds. Previously, these units existed in the game state but
were never assigned to a province after the battle ended.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-22 14:01:14 -08:00
d4089ae399 Remove stray git conflict marker from map_editor.js (#6214)
A rebase conflict marker was accidentally left in the file, causing
a JavaScript syntax error that broke the map editor entirely.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:59:22 -08:00
15955ff139 Fix race conditions in reinforcement hero headshot resolution (#6210)
* Fix race conditions in reinforcement hero headshot resolution

Two race conditions prevented headshots from showing for mid-battle
reinforcement heroes:

1. The eagle NewHeroes update and Shardok ChangedReserveUnits update
   arrive as separate messages with no guaranteed ordering. If the
   Shardok update arrives first, units are created with null headshot
   and name data.

2. Streaming hero name text may not have arrived in ClientTextProvider
   when the dialogue fires, so name-based matching fails.

Fix: propagate NewHeroes from the eagle model to all running
ShardokGameModels (backfilling any units created with missing data),
and retry headshot resolution each frame in DialogueManager until it
succeeds.

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

* Use case-insensitive name matching and model display names for dialogue

Replace ResolveHeadshotPath with ResolveSpeaker that:
- Uses case-insensitive name matching (fixes "Hedge-Merchant" vs
  "Hedge-merchant" mismatch for Hedrick)
- Returns both the headshot path AND the display name from the model,
  so the dialogue shows the canonical hero name from the game data

Remove the per-frame retry mechanism — reinforcement heroes are in the
eagle game state from the start, so there is no race condition.

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

* Remove unnecessary RegisterNewHeroes propagation

Reinforcement heroes are in the eagle game state from tutorial
creation, so they're already in the ShardokGameModel's dictionaries
when the battle starts. No need to propagate them later.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:58:49 -08:00
fed04aec7c Scale tile images to fill pointy-top hex height (#6212)
The tile images contain flat-top hexes (height = sqrt(3)/2 * width),
but our grid uses pointy-top hexes (height = 2/sqrt(3) * width) which
are ~15% taller for the same width. Scale based on the flat-top hex
height within the source image so the hex content fills the full
canvas hex vertically. Excess width is clipped.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:49:13 -08:00
625155a6c6 Scale tile images to fill pointy-top hex height (#6211)
The tile images contain flat-top hexes (height = sqrt(3)/2 * width),
but our grid uses pointy-top hexes (height = 2/sqrt(3) * width) which
are ~15% taller for the same width. Scale based on the flat-top hex
height within the source image so the hex content fills the full
canvas hex vertically. Excess width is clipped.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:40:12 -08:00
68a6037cc6 Add light cavalry to Ikhaan Tarn's tutorial army (#6206)
Add Silvio the Smooth as a 5th hero in province 31 (before Aldric who
stays behind), and add a light cavalry battalion (size 450) to the
attacking force.

Also give the battalions proper names:
- Doomriders (heavy cavalry)
- The Shardok's Guard (heavy infantry)
- Bowmen of Nikemi (longbowmen)
- Swift Sabres (light cavalry)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-22 13:31:54 -08:00
b186c059f4 Fix tile image vertical centering in map editor (#6209)
Center the tile image on the hex center (center.y - dh/2) instead of
aligning the image top with the hex top vertex (center.y - HEX_SIZE).
The scaled image is taller than the hex and the hex clip handles
cropping the excess.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:17:47 -08:00
be275a5aaf Revise tutorial dialogue: Tarn characterization, dragoons, reinforcement intros (#6207)
- Remove "cruelty" and "madman" from Tarn description; replace with
  erratic/paranoid/inscrutable behavior (he's not evil, just alarming)
- Add dragoons to Marek's unit rundown and instruction panel
- Rewrite reinforcement hero intros to reference backstories and the
  strategic situation rather than self-introductions of abilities

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:15:04 -08:00
9977fc965a Fix reinforcement hero headshots and make bridge scale configurable (#6205)
Reinforcement heroes (e.g. John Ranil) arrived mid-battle with no
headshot or name because ShardokGameModel's hero dictionaries were
only populated at battle creation. Add a fallback lookup to the eagle
model's Heroes collection so mid-battle heroes resolve correctly.

Also extract the hardcoded bridge scale (15) to an Inspector field on
HexGrid, defaulting to 22.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:02:58 -08:00
3076a6727e Add tutorial reinforcement heroes/battalions to Eagle game state (#6203)
Tutorial reinforcement heroes (IDs 100, 101, 102) were being created as
CommonUnit objects for Shardok but never added to Eagle's GameState. This
caused a NoSuchElementException when ResolveBattleAction tried to look up
these heroes after the battle resolved.

The fix adds the reinforcement heroes and battalions to the game state
during tutorial game creation:
- Create HeroC objects from LoadedHero data with specific IDs (100-102)
- Create BattalionC objects with matching IDs
- Add both to the actionResult's newHeroes and newBattalions

The heroes are assigned to the player faction but not to any province,
representing "off-map" reinforcements that will appear during battle.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-22 13:02:41 -08:00
d89f2c8894 Fix map editor hex geometry, image positioning, and castle tiles (#6204)
Three fixes:

1. Hex overlap: The hexCenter/pointInHex formulas used flat-top spacing
   (col * 1.5 * s, row * sqrt(3) * s) for a pointy-top hex layout.
   Swap to correct pointy-top formulas (col * sqrt(3) * s, row * 1.5 * s).

2. Image vertical offset: Tile images (256x384) have hex art in the
   upper portion with depth effect below. Align image top with hex top
   instead of centering, so the hex art matches the clip region.

3. Castles: Render the castle tile image (hexPlainsCastle00.png) when a
   tile has a castle modifier, matching how Shardok renders castles.
   Remove the brown box overlay for castles.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:44:40 -08:00
0a7f0e4ad5 Add tutorial reinforcements dialogue with dynamic headshots (#6168)
* Add tutorial reinforcements dialogue with dynamic headshot resolution

When reinforcements arrive in the tutorial battle, Marek introduces three
allied heroes (Ranil/Engineer, Fyar/Paladin, Hedrick/Ranger) who each
explain their profession abilities. Hero headshots are resolved at display
time from the ShardokGameModel, the same way UnitInfoPanelController does
it, rather than hardcoding image paths in the dialogue JSON.

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

* Fix dialogue queuing, charge trigger timing, and start fire condition (#6170)

- Queue dialogue triggers when another dialogue is active so they play
  after the current one ends (fixes reinforcements being dropped when
  thunderstorm dialogue was showing)
- Trigger charge tutorial when a move command has a charge follow-up,
  not when the charge command itself is available (too late)
- Require EnemyHostility for start fire tutorial, not just non-self

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:26:38 -08:00
613cf529bc Rename Fluria to East Faluria and Faluria to West Faluria (#6201)
* Add documentation for renaming a province

Documents the files that need to be modified when renaming a province
and explains how province names flow from the server to the client.

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

* Rename Fluria to East Faluria and Faluria to West Faluria

Updated all province name references:
- province_map.tsv: Both province rows and all neighbor references
- MapDescription.scala: LLM prompt geography description
- centroids.json: Client map rendering metadata
- RuntimeValidatorTest.scala: Test strings
- Renamed hex map files: Fluria.e0mj -> East_Faluria.e0mj,
  Faluria.e0mj -> West_Faluria.e0mj

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-22 11:23:54 -08:00
ca38c1df3a Use context-appropriate highlight colors for province borders (#6202)
- Override (faction hover/notification): always black — visible against
  any faction color
- Targeted (right-click): deep purple-red — distinct from red faction
- Selected (left-click): luminance-adaptive — dark factions get white
  highlight, bright factions get black

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:25:46 -08:00
13777a1e83 Skip same-faction borders during faction highlighting (#6200)
When hovering a faction in the Factions table or viewing a faction
notification, borders between provinces of the same faction are now
suppressed so the faction's territory appears as one contiguous
highlighted region.

Adds a cross-province ID map (computed once at startup alongside the
distance map) that stores which province is on the other side of each
pixel's nearest border, plus a 256x1 highlight group lookup texture
that the shader checks to skip highlight borders between same-group
provinces.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:09:28 -08:00
782dc2135a Fetch LFS tile images before admin server Docker build (#6199)
The tile PNGs in static/tiles/ are LFS-tracked but the Docker build
workflow checks out with lfs: false, embedding LFS pointer files
instead of actual images. Add a targeted git lfs pull for just the
admin server tile images (~736KB) before the Bazel build.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:05:25 -08:00
a23ba79190 Fix LLM provider selection to prefer primary provider (#6198)
The selectCaller method was load-balancing across all available LLM
providers by selecting whichever caller had the fewest in-flight
requests. This caused requests to be distributed roughly evenly across
Gemini, Claude, and OpenAI instead of preferring the primary provider
(Gemini) and only falling back on capacity limits.

Now selectCaller first tries to find a caller from the primary provider
with available capacity, and only falls back to other providers when the
primary has no capacity.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 21:45:05 -08:00
a1dcb8e932 Make province borders resolution-independent (#6196)
* Make province borders resolution-independent using fwidth()

Use screen-space UV derivatives to compute texel-to-pixel ratio in the
shader, so border widths specified in screen pixels remain visually
consistent regardless of render resolution.

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

* Reduce default border width from 0.7 to 0.5 screen pixels

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:41:56 -08:00
328fa44bf4 Fix static file embedding to include subdirectories (#6197)
The go:embed directive used `static/*` which only matches direct
children. Change to `all:static` so subdirectories like `static/tiles/`
are included in the binary.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:33:44 -08:00
4f0ecf66c6 Use hex tile images in map editor instead of flat colors (#6195)
Copy representative PNG tile images from the Unity assets into the admin
server static directory and render them clipped to hex shapes on the
canvas. Falls back to flat CSS colors if an image fails to load.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:23:02 -08:00
9a014b38c4 Tweak strategic map UI layout and border width (#6194)
Update province border width from 0.35 to 0.7 and adjust UI element
positioning in the Gameplay scene.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 18:17:59 -08:00
f6ed3d2bee Merge disconnected Parnia blob into Ingia (#6193)
Reassign 1,242 pixels from a small disconnected blob of Parnia (pid 41)
in the southwest to Ingia (pid 32), which surrounds it.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 18:12:39 -08:00
d92d37ea68 Hand-tune province label positions (#6192)
* Hand-tune province label positions for border-adjacent provinces

Manual adjustments on top of the algorithmic positioning for provinces
where labels were too close to borders:
- Berkorszag: +30px right
- Kezonoria: +20px right, +5px up
- Grytrand: +15px right
- Oscasland: +20px up
- Oryslia: 10px left
- Pozia: 5px up
- Chia: 5px right

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

* Further tweak Kezonoria and Pozia label positions

Kezonoria: +25px up total, Pozia: +15px up total (increasing y = up in
Unity's coordinate system).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 18:11:41 -08:00
f5ed0de040 Improve label positioning with clearance-threshold algorithm (#6187) (#6191)
Replace Gaussian-weighted blend with simpler, more predictable approach:
find all pixels with >= 70% of max possible edge clearance, then pick the
one closest to the geometric centroid. This keeps labels well inside province
boundaries while staying visually centered. Most labels barely move from
their current positions; labels near narrow borders (Kezonoria, Grytrand)
get nudged further inside.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:56:28 -08:00
8ff56d7d28 Enable map editor in production admin server deployment (#6190)
Add .e0mj map files as a layer in the admin server Docker image
and pass --maps-dir=/app/maps to enable the web-based map editor
added in #6186.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:53:20 -08:00
66e8f2f605 Delete orphaned .e0s battle files on game rewind (#6189)
When a game is rewound past a battle, truncateTo() cleared in-memory
shardokHistory but left .e0s files on disk. On restart, these orphaned
files got lazy-loaded when the game state still referenced the battle,
causing Eagle to re-send stale battle data to Shardok.

This caused a production crash loop where Eagle kept sending an old
Motcia battle (with pre-fix map data baked into the .e0s file) on
every restart, even after the map fix was deployed.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:53:03 -08:00
9c557873c4 Fix province label positions using pole-of-inaccessibility algorithm (#6188)
Replace geometric centroid (mean of pixels) with a distance-transform-based
approach that finds the point inside each province maximally distant from
edges, weighted by proximity to the visual center. This fixes labels that
bled into neighboring provinces or extended over water (Berkorszag, Chia,
Pozia).

Also adds --update-centroids mode to generate_map.py for re-running label
positioning without losing hand-tuned styling fields.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:16:17 -08:00
ee8dfba000 Add "Restart Eagle" button to admin console (#6187)
Uses Docker Engine API over mounted Unix socket to restart the active
Eagle container directly from the admin UI. Includes confirmation dialog
and graceful degradation when Docker socket is unavailable.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:41:22 -08:00
98b456d760 Add web-based map editor to admin console (#6186)
Replaces the Unity-based Shardok map editor with a browser-based editor
in the admin console. Supports terrain painting, modifiers (castle/bridge),
starting positions, weather config, undo/redo, and file download.

Enabled via --maps-dir flag pointing to the .e0mj directory.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:39:17 -08:00
413df8bdff Add eagle-restart script for quick active instance restart (#6185)
Useful when Eagle needs to reload game state from persistence
(e.g., after a rewind) without a full blue-green deployment.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:21:09 -08:00
c1463ca9c5 Add CI test for attacker/defender starting position overlap (#6184)
Validates that no map has attacker starting positions that overlap
with defender starting positions. This overlap causes the AI's
GameStateGuesser to misplace guessed defender units, leading to
command count mismatches during SET_UP.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:27:27 -08:00
d4572c2108 Fix attacker/defender starting position overlap on Motcia map (#6183)
Motcia's attacker starting position index 0 had 7 of 10 positions
overlapping with defender starting positions. This caused the AI's
GameStateGuesser to place a guessed defender unit on an attacker
starting position during SET_UP, blocking it and creating a command
count mismatch (27 vs 30) that crashed the server in a loop.

Moved attacker index 0 positions east into the plains (columns 3-5)
away from the defender's castle compound at (1,0)/(2,0)/(2,1).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:36:50 -08:00
223e059e72 Add detailed coords and occupant logging to AI command mismatch diagnostic (#6182)
The guessed state produces 27 PLACE_UNIT_COMMANDs vs 30 real during SET_UP.
This adds target coordinates to the command dump, identifies the specific
missing positions per unit, checks what the guessed state thinks is occupying
them, and dumps all guessed state units for full visibility.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:27:51 -08:00
5077981d41 Replace AI command count assert with diagnostic error logging (#6181)
The assert at ShardokAIClient.cpp:174 was crashing the server with
no diagnostic info when guessed and real command counts diverged.
Now logs both counts, player, round, and a side-by-side dump of
each command (type + actor unit) before throwing an exception that
the server can catch and recover from.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:09:16 -08:00
f352d7aca8 Fix duplicate heroes by threading HeroGenerator through game creation (#6180)
The HeroGenerator was being passed to EngineImpl before heroes were
consumed from it during game creation. The randomHeroes() method
consumed heroes from the generator pool but then discarded the updated
generator with .map(_._1). This meant the engine started with a
generator that still contained heroes that had already been spawned.

When new heroes spawned during gameplay, they would duplicate the
heroes already in the game.

Fix: Thread the updated HeroGenerator through the game creation flow
and pass the final generator (after all initial heroes are consumed)
to createEngine().

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 20:27:50 -08:00
c85ce61bef Remove users from lobby when entering a game (#6179)
When a user enters a game via StreamGameRequest, they were remaining in
the lobbyUsers map, causing unnecessary lobby updates to be broadcast to
them and triggering repeated disk reads of games.e0es. Now users are
removed from the lobby when they start streaming a game. They are re-added
when they send EnterLobbyRequest upon returning to the lobby screen.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 20:02:21 -08:00
1ccd5784ee Fix tutorial reinforcement hero ID verification (#6171)
* Change tutorial reinforcements to appear after round 5

Adjusts timing for better tutorial pacing.

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

* Fix tutorial reinforcement hero ID verification

When tutorial reinforcements cause a battle to end, the verification in
ResolveBattleAction was failing because it expected only the originally
sent hero IDs but received the reinforcement hero IDs (100, 101, 102) as
well. This adds reinforcementHeroIds to ShardokBattle and includes them
in the expected heroes set during verification.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 19:46:35 -08:00
5814fb31e5 Tint province background during blizzard (#6178)
Apply a subtle cool blue-gray tint to province colors when a blizzard
event is active, so white snowflakes are visible on light provinces.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:43:09 -08:00
ddfd735e15 Triple blizzard intensity on Eagle strategic map (#6177)
Increase snowEmitRate from 15 to 45 (3x) in both the prefab and code
default, and raise maxParticles from 300 to 900 to accommodate the
higher emission rate.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:42:54 -08:00
d9506fc0a0 Double default province border width from 0.35 to 0.7 (#6176)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:12:27 -08:00
1f0da81ed3 Fix reinforce command crash and broken overlay (#6175)
When clicking a reserve unit during combat, _selectedGridIndex was
cleared unconditionally, losing track of which hex unit is the actor
for the Reinforce command. Now only clear the selection during setup
phase. Also add null guards in RedrawCommandOverlays to prevent NRE
when SelectedCoords or the unit at those coords is null.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:02:25 -08:00
64a15dd616 Smooth province borders and reduce default width (#6174)
* Smooth province borders and reduce default border width

Switch distance-to-border computation from Chebyshev (L∞) to Chamfer
(Euclidean approximation) using √2 diagonal weights, producing smooth
circular contours instead of blocky square stepping. Enable bilinear
texture filtering for additional sub-texel smoothing. Reduce default
border width from 2.0 to 1.5 texels for a cleaner look.

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

* Reduce default border width to 0.75 texels

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

* Make default borders much thinner (account for double-sided drawing)

Both provinces on each side of a boundary draw their own border, so the
visual width is ~2x the configured value. Reduce width to 0.35 texels
(~0.7 total) and tighten the AA ramp from 0.75 to 0.5 for a true
hairline border.

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

* Fix border width: update serialized scene value to 0.35

The scene's serialized defaultBorderWidth (2.0) was overriding the C#
default at runtime via Awake(). Update the Gameplay.unity scene to match.

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

* Warp province boundaries for organic look

Apply domain warping to rawGray.gz.bytes using Gaussian-filtered noise
displacement fields. Only land pixels near province boundaries are
affected; ocean and coastline pixels are preserved. Adds a reusable
warp_boundaries.py script with configurable amplitude, scale, and seed.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:01:22 -08:00
ad58e4cac2 Make rubber-banding hero spawns appear as residents (#6173)
Rubber-band spawned heroes (for struggling factions) now appear as
Residents instead of Travelers. This makes them more likely to stay
in the province and be recruited by the faction they were spawned to
help. Added optional unaffiliatedHeroType parameter to
UnaffiliatedHeroAppearedAction to support this.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 19:01:04 -08:00
d9e5b47e7f Fix available games not displaying in lobby list (#6172)
Instantiate was called with worldPositionStays=true, causing items to
retain their prefab world position instead of being laid out by the
parent layout group. Changed to false to match running/waiting games.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:00:45 -08:00
a8842b0cd3 Change tutorial reinforcements to appear after round 5 (#6169)
Adjusts timing for better tutorial pacing.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 17:55:12 -08:00
044819cd99 Increase tutorial defender unit size and training (#6167)
- Sadar Rakon's battalions increased by 30% size
- Training increased from 60 to 80
- Makes the tutorial battle more challenging

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 16:29:00 -08:00
6471a889a1 Use attacker starting position index for tutorial reinforcements (#6166)
Instead of hardcoding specific (row, column) coordinates in the proto
from Eagle, pass an attacker_starting_position_index that Shardok
resolves against the hex map. Uses index 6 for the tutorial battle
on Onmaa.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:42:33 -08:00
115948eea3 Add tutorial reinforcement units to initial game state (#6165)
* Add PENDING_REINFORCEMENT units to game state during tutorial setup

Tutorial reinforcement units (defined in TutorialBattleConfig events)
were never added to the initial game state, so ExecuteReinforcementsAction
could not find any units to activate. Now SetUpController() extracts
CommonUnit protos from reinforcement events, converts them via ConvertUnit(),
and adds them with PENDING_REINFORCEMENT status so they exist in the game
state when the trigger fires.

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

* Exclude PENDING_REINFORCEMENT units from PlacedUnitsForPlayer

PlacedUnitsForPlayer returned all non-RESERVE_UNIT units, which
incorrectly included PENDING_REINFORCEMENT units. This caused them
to appear during the initial unit placement phase. Now they are
excluded alongside RESERVE_UNIT so they remain invisible until
activated by the tutorial trigger.

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

* Preserve PENDING_REINFORCEMENT status in SetupInitialGameState

SetupInitialGameState was overwriting every unit's status to
RESERVE_UNIT, which erased the PENDING_REINFORCEMENT status set
during tutorial game setup. Now units that are already marked
PENDING_REINFORCEMENT retain their status.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:58:15 -08:00
58e674d3fd Use cached leader info for unloaded games in lobby (#6164)
The games.e0es file stores factionLeaderCache with resolved names,
but gamesForWithoutBlocking() was ignoring this cache and always
showing "[Loading...]" for unloaded games. Now uses the cached
leader info when available, only falling back to placeholder when
the cache is missing or has no resolved name.

This eliminates the "Loading..." delay in the lobby after deployment.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 10:54:07 -08:00
c94d41952c Avoid unnecessary Unity reimport of generated proto files (#6163)
Stage proto outputs in a temp directory and rsync with --checksum
to preserve timestamps on unchanged files. Previously, rm -rf on
the output directory forced Unity to reimport all protos (~7 min
of script recompilation) even when nothing changed.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:29:35 -08:00
3b536f93e2 Replace province fill-color strobing with border-width strobing (#6162)
* Replace province fill-color strobing with border-width strobing

Province highlights (selected, targeted, commands available) now strobe
border thickness instead of fill color, making them visible on small
provinces and dark faction colors. A runtime distance-to-border texture
enables variable-width borders with one extra texture sample per fragment.

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

* Restore fill-color strobing, use black border for selection, remove map_borders.png

- Restore fill-color strobing alongside border-width strobing
- Change selected province border color from white to black for contrast
- Remove map_borders.png and make ClickDetector overlay transparent
  (borders now rendered by shader)
- Rename MapBWImage to ClickDetector to reflect its purpose

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:49:22 -08:00
d0a07febe7 Replace environment dropdowns with Connect to QA buttons (#6161)
Replace the prod/QA environment dropdowns (connection panel and lobby) with
simple "Connect to QA" buttons that connect directly to localhost:40032 over
plain HTTP with no auth. Buttons are editor-only and hidden in builds.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:15:36 -08:00
10dbff3738 Enlarge dialogue sprites and add charge/profession icons (#6160)
* Add charge icon sprite asset and use inline in dialogue

Create TMP sprite asset for the charge horse icon and add it to
the fallback chain. Update charge tutorial instruction text to
show the icon inline instead of describing it as "horse icon".

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

* Add profession icon sprite assets for dialogue use

Create TMP sprite assets for Mage, Necromancer, Engineer, Paladin,
Ranger, and Champion profession icons. Added to the fallback chain
on the primary dragoons sprite asset. Available as
<sprite name="Mage"> etc. in rich text.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:23:09 -08:00
78e130b9dd Add --skip-auth flag for local QA testing (#6159)
When --skip-auth is passed to Eagle server, all requests are
auto-authenticated as 'local-dev' user with admin privileges.
This bypasses JWT validation entirely for local testing.

WARNING is printed to stdout when this flag is active.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 07:15:20 -08:00
e9d724ed88 Fix Waylaid Julius vigor exceeding constitution (#6157)
Vigor (99) was higher than constitution (91), causing a validation
error during battle resolution. Set vigor to match constitution.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 06:53:10 -08:00
8cf22c0bd0 Increase inline sprite size by 25% (#6158)
Scale battalion type sprites from 1.0 to 1.25 for better readability
in dialogue instruction text.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 06:52:50 -08:00
80d8927d6a Fix Start Fire tutorial trigger and clean up sprite asset approach (#6156)
* Add inline battalion type icons to dialogue instruction text

Build a TMP_SpriteAsset at runtime from EagleCommonTextures battalion
textures, enabling <sprite name="LightInfantry"> etc. in rich text.
The unit types panel now shows each battalion's icon next to its name.

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

* Fix read-only spriteCharacterTable/spriteGlyphTable assignment

Use existing lists via .Add() instead of assigning new ones — these
properties are read-only in TMP 3.x.

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

* Fix non-readable texture error in sprite asset builder

Copy battalion textures to readable copies via RenderTexture blit
before packing into atlas, since the originals may not have Read/Write
enabled in their import settings.

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

* Remove runtime TMP_SpriteAsset builder in favor of editor-created asset

The runtime approach fought TMP's initialization — property getters
trigger UpgradeSpriteAsset() which crashes on freshly created instances.
A pre-built sprite asset assigned in the Inspector is the intended workflow.

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

* Only trigger Start Fire tutorial when target has an enemy unit

The start_fire_available event was firing whenever the command existed,
even when targeting empty hexes. Now checks that the command's target
cell contains a non-friendly unit before triggering the dialogue.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:28:19 -08:00
c75f7d8dda Suppress CANCELLED gRPC errors in Sentry reporting (#6155)
CANCELLED status is transient and will be handled by reconnection logic,
so there's no need to report it to Sentry alongside UNAVAILABLE.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 22:15:29 -08:00
cc710e0f6b Add inline battalion type icons to dialogue text (#6153)
* Add inline battalion type icons to dialogue instruction text

Build a TMP_SpriteAsset at runtime from EagleCommonTextures battalion
textures, enabling <sprite name="LightInfantry"> etc. in rich text.
The unit types panel now shows each battalion's icon next to its name.

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

* Fix read-only spriteCharacterTable/spriteGlyphTable assignment

Use existing lists via .Add() instead of assigning new ones — these
properties are read-only in TMP 3.x.

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

* Fix non-readable texture error in sprite asset builder

Copy battalion textures to readable copies via RenderTexture blit
before packing into atlas, since the originals may not have Read/Write
enabled in their import settings.

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

* Remove runtime TMP_SpriteAsset builder in favor of editor-created asset

The runtime approach fought TMP's initialization — property getters
trigger UpgradeSpriteAsset() which crashes on freshly created instances.
A pre-built sprite asset assigned in the Inspector is the intended workflow.

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

* Add inspector-assigned sprite asset for dialogue instruction icons

Replace runtime sprite builder with a TMP_SpriteAsset field on
DialoguePanelController. Sprite assets are created in the editor
and wired via Inspector — the intended TMP workflow.

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

* Fix sprite vertical alignment and adjust panel background color

Increase m_HorizontalBearingY from 128 to 230 on all sprite assets
so icons align with adjacent text instead of sitting below it.
Update instruction panel background for better icon readability.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:15:16 -08:00
377c1fe726 Increase Sadar Rakon's agility to enable archery in tutorial (#6154)
Changed agility from 70 to 75 (minimum threshold for archery).
Updated stat sum from 430 to 435.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 22:12:59 -08:00
17d2a72ea9 Add battle tutorial dialogues with Old Marek (#6152)
* Add battle tutorial dialogues with Old Marek

Narrative dialogue panels for the Shardok tactical combat tutorial:

- Placement phase: enemy forces (red text), unit types, placement instructions
- Battle running: victory conditions, hold position / End Turn advice
- Archery: purple outlines on targets, longbow effectiveness vs armor
- Melee: Shift-click and Melee button instructions
- Charge: horse icon on move destinations, move-and-charge explanation
- Thunderstorm: prevents archery, extinguishes fires
- Start Fire: setting hexes ablaze when adjacent to enemy

Supporting changes:
- Panel positioning (top/default) via panelPosition field on DialogueScript
- Completion tracking prevents dialogues from re-triggering
- New trigger events: archery_available, melee_available, start_fire_available, thunderstorm
- Register CommitButton and EndTurnButton as tutorial targets

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

* Clean up repetitive dialogue text in battle opening

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:27:17 -08:00
91dab8a28b Add playerId and starting positions to tutorial reinforcements config (#6151)
The ReinforcementsAction was missing playerId and startingPositions, so
the C++ side had no positions to place units at. Set playerId to 1
(defender) and use 3 positions from attacker starting position list 7
on the Onmaa map.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:22:40 -08:00
ebf9a3934f Implement The Eagle's appearance in tutorial (#6149)
* Implement The Eagle's appearance in tutorial

When Bridget loses all provinces in tutorial mode, The Eagle (Fracture
Covenant) appears and takes control of provinces 32 and 6, displacing
any existing occupants to adjacent friendly provinces.

- Add TutorialStrategicEvents to check for tutorial-specific events
- Add EagleAppearsAction to create The Eagle faction with battalions
- Add TutorialEagleAppears and TutorialDisplacement ActionResultTypes
- Hook tutorial events into EngineImpl.withUpdateChecks
- Update visibility in BUILD files for tutorial package access

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

* Move tutorial code from library to service to fix layering violation

The tutorial code was in library/tutorial/ which violated the architecture
where library/ should be pure game logic without proto/service dependencies.

Changes:
- Move EagleAppearsAction and TutorialStrategicEvents to service/tutorial/
- Add applyActionResults method to Engine trait for service-layer events
- Hook tutorial events into GameController.withHandledEngineAndResults
- Update BUILD.bazel visibility rules for the new location

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

* Rename TUTORIAL_EAGLE_APPEARS to TUTORIAL_FACTION_APPEARS

More generic name for potential reuse with other factions.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 21:19:09 -08:00
5cd1b372cc Fix flaky Hetzner deploy by pre-warming NAT64 and retrying docker pull (#6150)
The Hetzner server uses IPv6-only networking with NAT64 for IPv4. The first
docker pull often fails because the NAT64 gateway hasn't warmed up. Add a
curl pre-warm to the DO registry and retry docker pull up to 3 times with
a 10s backoff.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:56:32 -08:00
d543967b44 Place tutorial reinforcements directly on battlefield instead of using placement phase (#6147)
Instead of changing PENDING_REINFORCEMENT units to RESERVE_UNIT and triggering
a placement phase, directly place them as NORMAL_UNIT at available starting
positions. This gives the player immediate control of reinforcement units.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:43:36 -08:00
69789dd22e Untrack Assembly-CSharp.csproj and gitignore GeneratedProtos.meta (#6148)
Assembly-CSharp.csproj is auto-generated by Unity and already matched by
the *.csproj gitignore pattern, but was still tracked from a prior commit.
GeneratedProtos.meta is a Unity meta file for a generated directory.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:41:00 -08:00
f5c24e68c8 Add narrative dialogue tutorial with Old Marek (#6146)
* Add narrative dialogue tutorial system with Old Marek opening

Replace mechanical modal tutorials with in-world character dialogue.
Old tutorial content is suppressed (early return in RegisterAll) but
preserved for reference. DialogueManager loads JSON scripts from
Resources/Dialogues/, plays them via DialoguePanelController with
speaker headshots, and integrates with the existing highlight system.

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

* Add province highlighting, strobe animation, and dialogue fixes

- Add province bounding box highlight via MapController proxy RectTransform
- Fix province highlight Y coordinate (byte array y=0 = bottom, matching anchors)
- Add strobe/pulse animation to all highlight borders
- Replace cross-canvas highlighting with child-of-target border approach
- End dialogue on battle transition to prevent highlight persistence
- Fix instruction text to match actual button label ("Battle!")
- Add highlightProvince field to DialogueStep for map province highlighting

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:37:45 -08:00
c00acc991b Fix tutorial faction IDs to match normal game (#6145)
- King (Bregos Fyar) is now faction 2 (was incorrectly 1)
- The Eagle (faction 1) is reserved for later
- Player (Sadar Rakon) remains faction 3
- Bridget remains faction 4

Also fixed GamesManager to assign player to faction 3 and
AI to factions 2 and 4.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 20:32:55 -08:00
1a9479aa17 Move tutorial faction config to JSON (#6144)
Add factionId and heroLoyalty fields to SetFaction proto message.
Tutorial parameters now specify:
- factionId: explicit faction ID for each faction
- heroLoyalty: loyalty value for heroes in that faction

This moves configuration out of hardcoded Scala into the JSON file,
making it easier to adjust tutorial setup.

Faction config:
- Sadar Rakon (Player): factionId=3, heroLoyalty=90
- Bregos Fyar (King): factionId=1, heroLoyalty=85
- Bridget: factionId=4, heroLoyalty=85

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 20:22:36 -08:00
3a5eb1f760 Tutorial setup improvements (#6142)
Faction IDs:
- King (Bregos Fyar) gets faction ID 1
- Eagle reserved at faction ID 2
- Player (Sadar Rakon) gets faction ID 3
- Other factions get ID 4+

Hero and province setup:
- Player heroes loyalty set to 90
- Provinces 14, 31, 39 start with 50 gold and 2000 food
- Tarn's army marches to Onmaa; Aldric the Overlooked stays to hold Nikemi

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 20:15:26 -08:00
3eaeb031b2 Send Shardok exceptions to Sentry via Eagle (#6143)
Shardok now returns proper gRPC status codes (INVALID_ARGUMENT for client
errors, INTERNAL for server errors) instead of re-throwing exceptions that
resulted in opaque UNKNOWN status. Eagle's ShardokInterfaceGrpcClient now
captures these and other errors via Sentry.captureException at all error
points, skipping transient UNAVAILABLE errors handled by reconnection.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:12:26 -08:00
1f29de9012 Pre-place reinforcement units with PENDING_REINFORCEMENT status (#6141)
Instead of creating reinforcement units at event time (which crashes the
ActionResultApplier's "battalion got larger" validation), pre-place them at
battle creation with a new PENDING_REINFORCEMENT status. When the tutorial
reinforcement event fires, simply change their status to RESERVE_UNIT.

Key changes:
- Add PENDING_REINFORCEMENT = 10 to UnitStatus enum in unit.fbs
- ActionResultApplier now reads status from changed unit bytes instead of
  hardcoding NORMAL_UNIT, so reinforcement units correctly get RESERVE_UNIT
- PlaceUnitCommand, PlaceHiddenUnitCommand, and ReinforceCommand now
  explicitly set NORMAL_UNIT status (previously relied on applier default)
- TutorialBattleController finds PENDING_REINFORCEMENT units and changes
  them to RESERVE_UNIT, removing the UnitConversions dependency
- All exhaustive UnitStatus switch statements updated with new enum value

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:12:34 -08:00
6640e423b7 Adjust tutorial event timing (#6140)
* Adjust tutorial event timing

- Reinforcements now appear after round 6 (was 5)
- Tarn's flight now triggers after round 7 (was 5)

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

* Replace Alakanda with Elena Fyar in reinforcements

Sadar Rakon is already a Champion, so swapping in Elena Fyar
(Paladin) for more profession variety.

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

* Remove Tarn flee events from tutorial

Removing both flee triggers (units lost and after round 7) -
keeping the battle to run its natural course.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 18:07:58 -08:00
1b18bb090f Adjust tutorial battle unit sizes for realism (#6139)
* Adjust tutorial battle unit sizes for realism

- Double the defender's longbowmen (200 -> 394)
- Add slight variance to all unit sizes to make them look battle-worn:
  - Defender Light Infantry: 300 -> 293, 308
  - Attacker Heavy Cavalry: 600 -> 592
  - Attacker Heavy Infantry: 500 -> 507
  - Attacker Longbowmen: 300 -> 291

The non-round numbers suggest these units have already been through combat.

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

* Replace Colin with Old Marek the Learned in tutorial

Old Marek will be used extensively in the tutorial, so adding
him as one of the player's starting heroes.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 17:29:34 -08:00
c6cb8b4c48 Fix visible map redraw when entering Shardok battle (#6138)
Defer hexGrid.SetUp() by one frame (EnqueueForNextUpdate instead of
Enqueue) so Unity's layout system fully settles after the Shardok
container activation. Previously, mapArea reported a stale size on
the setup frame, causing HexGrid.Update() to detect a size change
and trigger RebuildGrid() on the next frame — visible as the map
snapping to a different size/position.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:24:34 -08:00
adminandGitHub 8f205e5e8e Gate tutorials on TUTORIAL game type (#6137) 2026-02-19 08:16:03 -08:00
b3b0de6b15 Expose GameType in client game model (#6136)
Wire GameStateView.GameType through IGameModel so the client can
read the game type (Normal vs Tutorial). Also apply NewGameType
from GameStateViewDiff when present.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:11:23 -08:00
4605492f32 Add reinforcement heroes to tutorial battle config at round 5 (#6135)
After round 5, the player's TutorialBattleConfig includes three reinforcement
units configured as CommonUnits:
- Hedrick the Hedge-Merchant with 600 longbowmen (75 training/armament)
- John Ranil with 500 knights (80 training/armament)
- Alakanda with 600 heavy infantry (75 training/armament)

These troops are better equipped than the player's starting forces.

Note: This only configures the reinforcements in the TutorialBattleConfig proto.
The C++ Shardok-side implementation to actually spawn the units in battle is
being done separately.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 07:10:54 -08:00
7626a4a958 Implement tutorial reinforcements with placement phase (#6134)
Add a reinforcement placement phase to tutorial battles so that when
reinforcement events fire, the receiving player can place new units
before combat resumes. This introduces a new REINFORCEMENT_PLACEMENT
game state that pauses the turn flow until all reinforcement units
are placed.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:10:30 -08:00
d07c3237f2 Sort running games by creation date in lobby (client) (#6132)
Sort lobby running games by CreatedTimestampMillis with GameId as
tiebreaker for existing games that share timestamp 0.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:40:14 -08:00
ad7b3fd0d1 Sort running games by creation date in lobby (server) (#6131)
Add created_timestamp_millis field to RunningGame (internal storage)
and GameInfo (client API) protos. Thread the timestamp through
ControllerInfo, GamePlayerInfo, and all lobby query paths in
GamesManager. Set timestamp on game creation (both regular and
tutorial). Existing games default to 0 (proto3 default).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:17:08 -08:00
8b64872a51 Remove diagnostic step and unnecessary clean:true from bazel_test (#6123)
The Verify workspace step was added to debug the sparse-checkout issue,
which is now fixed by the Clean workspace step. Also removes clean:true
which runs git clean -ffdx and destroys bazel-* symlinks unnecessarily.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:44:45 -08:00
17f9521780 Fix tutorial battle flee action and make only Tarn flee (#6128)
* Fix tutorial battle flee action player ID

The FleeAction was using playerId=1 (defender/player) instead of
playerId=0 (attacker/Tarn). This caused the player's units to flee
instead of Ikhaan Tarn's units.

In Shardok battles:
- playerId=0 is the attacker
- playerId=1 is the defender

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

* Add eagle_hero_ids to FleeAction for hero-specific fleeing

Extends FleeAction proto to support targeting specific heroes by their
Eagle hero ID. When eagle_hero_ids is specified, only units with matching
attached heroes will flee.

In the tutorial, this makes only Ikhaan Tarn flee when triggered, rather
than all of his units.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 16:43:56 -08:00
2a3ad4cad4 Use pre-built protoc binary instead of compiling from source (#6129)
Enable --incompatible_enable_proto_toolchain_resolution and
--prefer_prebuilt_protoc so Bazel downloads a pre-built protoc
binary from official protobuf releases rather than compiling it.
Protoc plugins and the runtime library still build from source.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:26:22 -08:00
5dfd9a7515 Fix skip-if-superseded: use curl instead of gh CLI (#6127)
The gh CLI isn't installed on all self-hosted runners. Replace with
curl + python3 which are available everywhere.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:31:35 -08:00
2021ee2ae8 Skip intermediate docker builds when newer runs are queued (#6126)
When multiple commits are merged to main in quick succession, intermediate
builds can be skipped if a newer docker_build run is already queued.
This avoids wasting runner time on builds that will be immediately
superseded, while never cancelling a build or deploy that's in progress.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:27:38 -08:00
37b4039f59 Skip intermediate docker builds when newer runs are queued (#6125)
When multiple commits are merged to main in quick succession, intermediate
builds can be skipped if a newer docker_build run is already queued.
This avoids wasting runner time on builds that will be immediately
superseded, while never cancelling a build or deploy that's in progress.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:52:55 -08:00
6e1a462c2c Populate hero names in tutorial game ClientTextStore (#6124)
The tutorial game creation was not calling withPopulatedPregeneratedTexts(),
causing hero names to show as "Hero" instead of their actual names because
the name text IDs couldn't be resolved.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 12:36:42 -08:00
84f165ea1d Add GameType enum to track tutorial vs normal games (#6121)
* Add GameType enum to track tutorial vs normal games

- Add GameType enum (NORMAL, TUTORIAL) in common proto and Scala model
- Store gameType in GameState and pass to GameStateView for client
- Add newGameType field to ActionResult for setting game type on creation
- Mark tutorial games as TUTORIAL in TutorialGameCreation

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

* Add newGameType field to GameStateViewDiff

Ensures game type changes are sent to clients via diffs, not just
the full GameStateView.

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

* Remove default value from GameState.gameType parameter

Force callers to explicitly specify gameType when constructing GameState,
making the type requirement more explicit and preventing accidental defaults.

Updates all test files and InMemoryHistory to explicitly pass GameType.Normal.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 12:29:41 -08:00
faaac39051 Add workspace cleanup and diagnostics to bazel_test workflow (#6122)
Self-hosted runners can get stuck in sparse-checkout mode from previous
deploy jobs. The actions/checkout@v4 sparse-checkout disable has a bug:
it writes core.sparseCheckout=false to .git/config.worktree, then
immediately unsets extensions.worktreeConfig, causing git to fall back
to .git/config where core.sparseCheckout is still true.

Fix: explicitly set core.sparseCheckout=false in .git/config and
remove the worktree config files entirely before checkout.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:46:39 -08:00
57d35e085a Remove sparse checkout from deploy job to prevent corrupting shared runners (#6120)
The deploy job in docker_build.yml used sparse-checkout on [self-hosted, bazel]
runners. This left the workspace in sparse-checkout mode, causing subsequent
jobs on the same runner to fail with "not invoked from within a workspace"
because MODULE.bazel was missing.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 07:06:50 -08:00
297 changed files with 10272 additions and 5090 deletions
+4
View File
@@ -5,6 +5,10 @@ common --ui_event_filters=-INFO
common --enable_bzlmod
# Use pre-built protoc binary instead of compiling from source
common --incompatible_enable_proto_toolchain_resolution
common --@com_google_protobuf//bazel/toolchains:prefer_prebuilt_protoc=true
# Don't use toolchains_llvm for the swift app build
common:mactools --ignore_dev_dependency
+18
View File
@@ -34,16 +34,34 @@ jobs:
lint:
runs-on: [self-hosted, bazel]
steps:
- name: Clean workspace
run: |
git sparse-checkout disable 2>/dev/null || true
git config --local core.sparseCheckout false 2>/dev/null || true
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Checkout repository
uses: actions/checkout@v4
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
with:
node-version: 'lts/*'
- name: Check JavaScript syntax
run: node --check src/main/go/net/eagle0/admin_server/static/map_editor.js
test:
runs-on: [self-hosted, bazel]
steps:
- name: Clean workspace
run: |
git sparse-checkout disable 2>/dev/null || true
git config --local core.sparseCheckout false 2>/dev/null || true
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Checkout repository
uses: actions/checkout@v4
with:
+28 -8
View File
@@ -31,13 +31,16 @@ on:
default: 'false'
type: boolean
# Only allow one deployment at a time to prevent race conditions
# Only allow one deployment at a time; queue new ones (never cancel a running deploy).
# The build-all job checks if it's still the latest commit on main and skips if not,
# so intermediate commits don't waste time building when a newer one is already queued.
concurrency:
group: docker-build-deploy
cancel-in-progress: false # Don't cancel running deployments, queue new ones
cancel-in-progress: false
permissions:
contents: read
actions: read
jobs:
# Single consolidated build job - builds all images with one bazel invocation
@@ -50,12 +53,33 @@ jobs:
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
steps:
- name: Skip if superseded
id: check-latest
run: |
# Check if there's a newer run of this workflow waiting in the queue.
# If so, skip this build — the queued run will deploy a newer commit.
QUEUED=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/actions/workflows/docker_build.yml/runs?status=queued" \
| python3 -c "import sys,json; runs=json.load(sys.stdin).get('workflow_runs',[]); print(len([r for r in runs if r['run_number'] > ${{ github.run_number }}]))")
if [ "$QUEUED" -gt 0 ]; then
echo "::notice::Skipping build — $QUEUED newer run(s) queued for this workflow"
echo "skip=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Checkout repository
if: steps.check-latest.outputs.skip != 'true'
uses: actions/checkout@v4
with:
lfs: false
- name: Fetch LFS files needed for admin server
if: steps.check-latest.outputs.skip != 'true'
run: git lfs pull --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
- name: Build all Docker images
if: steps.check-latest.outputs.skip != 'true'
id: build-all
run: |
set -ex
@@ -92,6 +116,7 @@ jobs:
echo "JFR Sidecar: $JFR_PATH"
- name: Upload warmup binary
if: steps.check-latest.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: warmup-binary
@@ -99,7 +124,7 @@ jobs:
retention-days: 1
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
if: steps.check-latest.outputs.skip != 'true' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true'))
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
@@ -222,11 +247,6 @@ jobs:
uses: actions/checkout@v4
with:
lfs: false
sparse-checkout: |
docker-compose.prod.yml
nginx
scripts
sparse-checkout-cone-mode: false
- name: Setup SSH key
run: |
+16 -2
View File
@@ -191,8 +191,22 @@ jobs:
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pull the new image
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
for attempt in 1 2 3; do
echo "Pull attempt $attempt..."
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: docker pull failed after 3 attempts"
exit 1
fi
echo "Pull failed, retrying in 10s..."
sleep 10
done
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
+1
View File
@@ -5,6 +5,7 @@ repos:
rev: v4.3.0
hooks:
- id: check-added-large-files
- id: check-merge-conflict
- id: no-commit-to-branch
args: [--branch, main]
- repo: https://github.com/pocc/pre-commit-hooks
+8
View File
@@ -243,6 +243,13 @@ pkg_tar(
package_dir = "/app",
)
# Package the .e0mj map files for the map editor
pkg_tar(
name = "admin_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps:maps_json"],
package_dir = "/app/maps",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
@@ -251,6 +258,7 @@ oci_image(
tars = [
":busybox_layer",
":admin_binary_layer",
":admin_maps_layer",
],
workdir = "/app",
)
+4
View File
@@ -208,6 +208,8 @@ services:
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
- "--maps-dir"
- "/app/maps"
environment:
# Secret for CI to authenticate client update notifications
NOTIFY_SECRET: "${NOTIFY_SECRET:-}"
@@ -215,6 +217,8 @@ services:
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${ADMIN_S3_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${ADMIN_S3_SECRET_KEY:-}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- auth
+79
View File
@@ -0,0 +1,79 @@
# Renaming a Province
This document explains the steps required to rename a province in Eagle0.
## Files to Modify
### 1. Province Map TSV (Server Source of Truth)
**File:** `src/main/resources/net/eagle0/eagle/province_map.tsv`
This is the primary source of province data. Each row contains:
- Province ID
- Province name
- Neighbor IDs
- Neighbor directions
- Province name (repeated)
- Neighbor names (dot-separated)
- Starting food
You need to:
1. Change the province name in its own row (columns 2 and 5)
2. Update the neighbor name references in all neighboring provinces (column 6)
Example: To rename "Fluria" to "NewName", you would need to update:
- Line 26: The Fluria row itself
- Lines 5, 10, 18, 24, 27, 38, 41: All provinces that list Fluria as a neighbor
### 2. LLM Map Description
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/MapDescription.scala`
This file contains a hardcoded geographic description of the map used in LLM prompts for generating narrative text. Update any references to the old province name.
### 3. Client Centroids JSON
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/centroids.json`
This JSON file contains province metadata used for map rendering (centroid positions, areas, orientations). Each entry has a `name` field that should be updated to match.
Note: The actual province name displayed to players comes from the server via the `ProvinceView` proto, not from this file. However, this file should be kept in sync for consistency and because it may be used for map label positioning.
### 4. Test Files
**File:** `src/test/scala/net/eagle0/eagle/library/util/validations/RuntimeValidatorTest.scala`
Update any test strings that reference the old province name.
## How Province Names Flow to the Client
Province names are sent from the Eagle server to the Unity client via the `ProvinceView` protobuf message:
```protobuf
message ProvinceView {
int32 id = 1;
string name = 6; // <-- Province name sent from server
...
}
```
The server reads province names from `province_map.tsv` at startup. The client receives names through the proto and displays them via `province.Name` in C# code. This means:
- Changing the TSV automatically updates what clients see
- No C# code changes are needed (the code uses `province.Name` generically)
- The `centroids.json` name field is for map rendering/positioning, not display
## Files That Do NOT Need Changes
- **Hero backstory TSV files** (`heroes.tsv`, `generated_heroes.tsv`) - These don't contain province name references
- **C# client code** - Uses `province.Name` from the server proto, not hardcoded names
- **Proto definitions** - No province names are hardcoded in protos
## Checklist
- [ ] Update `province_map.tsv` - province's own row
- [ ] Update `province_map.tsv` - all neighbor references
- [ ] Update `MapDescription.scala`
- [ ] Update `centroids.json`
- [ ] Update any test files with province name references
- [ ] Build and run tests: `bazel test //src/test/scala/...`
+13 -3
View File
@@ -35,8 +35,13 @@ SUBDIRS=(
/bin/echo "Building C# protobuf sources..."
bazel build "${TARGETS[@]}"
/bin/echo "Copying generated .cs files to $OUTPUT_DIR..."
rm -rf "$OUTPUT_DIR"
/bin/echo "Syncing generated .cs files to $OUTPUT_DIR..."
# Stage new files in a temp directory, then rsync to preserve timestamps
# on unchanged files. This avoids triggering Unity reimport when protos
# haven't changed, saving ~7 minutes of script recompilation.
STAGING_DIR=$(mktemp -d)
trap "rm -rf '$STAGING_DIR'" EXIT
for i in "${!TARGETS[@]}"; do
target="${TARGETS[$i]}"
@@ -50,9 +55,14 @@ for i in "${!TARGETS[@]}"; do
name="${target_path##*:}"
bin_dir="$REPO_ROOT/bazel-bin/$pkg/$name"
dest_dir="$OUTPUT_DIR/$subdir"
dest_dir="$STAGING_DIR/$subdir"
mkdir -p "$dest_dir"
find "$bin_dir" -name "*.cs" -exec cp {} "$dest_dir/" \;
done
# Sync only changed files and delete removed ones; --checksum compares
# content not timestamps so unchanged files keep their original mtime.
mkdir -p "$OUTPUT_DIR"
rsync -rc --delete "$STAGING_DIR/" "$OUTPUT_DIR/"
/bin/echo "Done. Generated C# proto sources in $OUTPUT_DIR"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
#
# Restart the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# This is useful when you need Eagle to reload game state from persistence
# (e.g., after a rewind) without doing a full blue-green deployment.
#
# Usage:
# eagle-restart # Restart active instance, wait for healthy
# eagle-restart --no-wait # Restart without waiting for health check
#
# To create an alias, add to ~/.bashrc:
# alias eagle-restart='/opt/eagle0/scripts/eagle-restart.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
HEALTH_TIMEOUT=120 # seconds
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
NO_WAIT=false
if [ "${1:-}" = "--no-wait" ]; then
NO_WAIT=true
fi
echo "Restarting ${ACTIVE}..."
docker restart "$ACTIVE"
if [ "$NO_WAIT" = true ]; then
echo "Restart initiated. Not waiting for health check."
exit 0
fi
echo "Waiting for ${ACTIVE} to become healthy (timeout: ${HEALTH_TIMEOUT}s)..."
elapsed=0
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "$ACTIVE" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
echo "${ACTIVE} is healthy after ${elapsed}s."
exit 0
fi
sleep 2
elapsed=$((elapsed + 2))
# Print progress every 10 seconds
if [ $((elapsed % 10)) -eq 0 ]; then
echo " ...${elapsed}s (status: ${health})"
fi
done
echo "Warning: ${ACTIVE} did not become healthy within ${HEALTH_TIMEOUT}s (status: ${health})" >&2
echo "Check logs with: eagle-logs" >&2
exit 1
@@ -20,6 +20,9 @@
#include <sstream>
#endif
#include <set>
#include <unordered_map>
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp"
#include "AIDefenderStrategySelector.hpp"
@@ -171,8 +174,117 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// }
// }
assert(commandCount == realAvailableCommands->size());
// Verify that the AI's guessed state produces the same available commands as reality
if (commandCount != realAvailableCommands->size()) {
fprintf(stderr,
"AI command count mismatch: guessed=%zu real=%zu player=%d round=%d\n",
commandCount,
realAvailableCommands->size(),
playerId,
guessedState->current_round());
const auto maxDump = std::max(commandCount, realAvailableCommands->size());
for (size_t i = 0; i < maxDump; i++) {
const auto *realName = i < realAvailableCommands->size()
? net::eagle0::shardok::common::CommandType_Name(
(*realAvailableCommands)[i]->GetCommandType())
.c_str()
: "(none)";
const auto *guessedName = i < commandCount
? net::eagle0::shardok::common::CommandType_Name(
(*guessedCommands)[i]->GetCommandType())
.c_str()
: "(none)";
const int realActor = i < realAvailableCommands->size()
? (*realAvailableCommands)[i]->GetActorUnitId()
: -1;
const int guessedActor =
i < commandCount ? (*guessedCommands)[i]->GetActorUnitId() : -1;
const int realRow = i < realAvailableCommands->size()
? (*realAvailableCommands)[i]->GetTargetRow()
: -1;
const int realCol = i < realAvailableCommands->size()
? (*realAvailableCommands)[i]->GetTargetColumn()
: -1;
const int guessedRow = i < commandCount ? (*guessedCommands)[i]->GetTargetRow() : -1;
const int guessedCol = i < commandCount ? (*guessedCommands)[i]->GetTargetColumn() : -1;
fprintf(stderr,
" [%zu] real: %s unit=%d (%d,%d) guessed: %s unit=%d (%d,%d)\n",
i,
realName,
realActor,
realRow,
realCol,
guessedName,
guessedActor,
guessedRow,
guessedCol);
}
// Find positions in real commands that are missing from guessed commands
// Group by unit to find the missing position per unit
std::unordered_map<int, std::set<std::pair<int, int>>> realPositions;
std::unordered_map<int, std::set<std::pair<int, int>>> guessedPositions;
for (size_t i = 0; i < realAvailableCommands->size(); i++) {
const auto &cmd = (*realAvailableCommands)[i];
realPositions[cmd->GetActorUnitId()].emplace(
cmd->GetTargetRow(),
cmd->GetTargetColumn());
}
for (size_t i = 0; i < commandCount; i++) {
const auto &cmd = (*guessedCommands)[i];
guessedPositions[cmd->GetActorUnitId()].emplace(
cmd->GetTargetRow(),
cmd->GetTargetColumn());
}
for (const auto &[unitId, positions] : realPositions) {
for (const auto &[row, col] : positions) {
if (!guessedPositions[unitId].contains({row, col})) {
fprintf(stderr,
" Missing from guessed: unit=%d pos=(%d,%d)",
unitId,
row,
col);
const Coords missingCoords(row, col);
const auto *occupant = guessedState.GetOccupant(missingCoords);
if (occupant) {
fprintf(stderr,
" -> guessed occupant: unit_id=%d player=%d status=%d "
"loc=(%d,%d) hidden=%d battalion_size=%d\n",
occupant->unit_id(),
occupant->player_id(),
static_cast<int>(occupant->status()),
occupant->location().row(),
occupant->location().column(),
occupant->hidden(),
occupant->battalion().size());
} else {
fprintf(stderr, " -> no occupant in guessed state\n");
}
}
}
}
// Dump all guessed state units for full picture
fprintf(stderr, " Guessed state units (%u total):\n", guessedState->units()->size());
for (size_t i = 0; i < guessedState->units()->size(); i++) {
const auto *u = guessedState->units()->Get(static_cast<unsigned int>(i));
fprintf(stderr,
" unit_id=%d player=%d status=%d loc=(%d,%d) hidden=%d "
"starting_pos_idx=%d\n",
u->unit_id(),
u->player_id(),
static_cast<int>(u->status()),
u->location().row(),
u->location().column(),
u->hidden(),
u->starting_position_index());
}
throw ShardokInternalErrorException(
"AI command count mismatch: guessed=" + std::to_string(commandCount) +
" real=" + std::to_string(realAvailableCommands->size()));
}
for (size_t i = 0; i < commandCount; i++) {
CheckCommand((*realAvailableCommands)[i], (*guessedCommands)[i]);
}
@@ -93,7 +93,8 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
throw ShardokInternalErrorException("Unknown unit status");
@@ -56,13 +56,18 @@ public:
commandFactories(CommandFactoriesList(settings).GetFactories()),
playerSetupCommandFactory(settings) {}
[[nodiscard]] auto GetPlayerSetupCommands(const GameStateW &gameState, PlayerId playerId) const
[[nodiscard]] auto GetPlayerSetupCommands(
const GameStateW &gameState,
PlayerId playerId,
const std::optional<std::vector<Coords>> &reinforcementPositions) const
-> CommandListSPtr override;
[[nodiscard]] auto GetAvailableCommands(
const GameStateW &gameState,
PlayerId player,
bool includeFollowUps) const -> CommandListSPtr override;
bool includeFollowUps,
const std::optional<std::vector<Coords>> &reinforcementPositions) const
-> CommandListSPtr override;
};
auto AvailableCommandsFactory::MakeAvailableCommandsFactory(const SettingsGetter &settings)
@@ -72,9 +77,14 @@ auto AvailableCommandsFactory::MakeAvailableCommandsFactory(const SettingsGetter
auto AvailableCommandsFactoryImpl::GetPlayerSetupCommands(
const GameStateW &gameState,
const PlayerId playerId) const -> CommandListSPtr {
const PlayerId playerId,
const std::optional<std::vector<Coords>> &reinforcementPositions) const -> CommandListSPtr {
CommandList previewCommands{};
playerSetupCommandFactory.AddAvailablePlayerSetupCommands(previewCommands, playerId, gameState);
playerSetupCommandFactory.AddAvailablePlayerSetupCommands(
previewCommands,
playerId,
gameState,
reinforcementPositions);
return make_shared<CommandList>(previewCommands);
}
@@ -168,12 +178,14 @@ void AvailableCommandsFactoryImpl::AddAvailableCommandsForOneUnit(
auto AvailableCommandsFactoryImpl::GetAvailableCommands(
const GameStateW &gameState,
PlayerId playerId,
bool includeFollowUps) const -> CommandListSPtr {
bool includeFollowUps,
const std::optional<std::vector<Coords>> &reinforcementPositions) const -> CommandListSPtr {
CommandList commands{};
vector<AdjacentTile> adjacentTiles;
playerSetupCommandFactory.AddAvailablePlayerSetupCommands(commands, playerId, gameState);
playerSetupCommandFactory
.AddAvailablePlayerSetupCommands(commands, playerId, gameState, reinforcementPositions);
if (!commands.empty()) return make_shared<CommandList>(commands);
for (const Unit *unit : *gameState->units()) {
@@ -9,8 +9,12 @@
#ifndef AvailableCommandsFactory_hpp
#define AvailableCommandsFactory_hpp
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/unit/Unit.hpp"
@@ -29,12 +33,16 @@ public:
[[nodiscard]] virtual auto GetPlayerSetupCommands(
const GameStateW &gameState,
PlayerId playerId) const -> CommandListSPtr = 0;
PlayerId playerId,
const std::optional<std::vector<Coords>> &reinforcementPositions = std::nullopt) const
-> CommandListSPtr = 0;
[[nodiscard]] virtual auto GetAvailableCommands(
const GameStateW &gameState,
PlayerId player,
bool includeFollowUps) const -> CommandListSPtr = 0;
bool includeFollowUps,
const std::optional<std::vector<Coords>> &reinforcementPositions = std::nullopt) const
-> CommandListSPtr = 0;
};
} // namespace shardok
@@ -58,6 +58,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/actions:new_round_action",
"//src/main/cpp/net/eagle0/shardok/library/command_factories:command_factories_list",
"//src/main/cpp/net/eagle0/shardok/library/commands:end_turn_command",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/unit",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
@@ -288,13 +288,13 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
// Check for tutorial events at end of round
if (!GameIsOver() && tutorialController_.IsEnabled()) {
auto tutorialResults = tutorialController_.CheckAndExecuteEvents(
auto tutorialEventResults = tutorialController_.CheckAndExecuteEvents(
gameState,
settingsGetter,
randomGenerator);
if (!tutorialResults.empty()) {
ApplyAndAddActionResults(tutorialResults);
if (!tutorialEventResults.actionResults.empty()) {
ApplyAndAddActionResults(tutorialEventResults.actionResults);
// Update game status after tutorial events (e.g., attackers fled, defender wins)
ApplyAndAddActionResults(UpdateGameStatusAction(
@@ -306,24 +306,29 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
// If tutorial events ended the game, don't start new round
if (GameIsOver()) { return; }
// If reinforcement placement is needed, pause the turn flow
if (tutorialEventResults.reinforcementPlacement.has_value()) {
pendingReinforcementPlacement_ = tutorialEventResults.reinforcementPlacement;
// Set state to REINFORCEMENT_PLACEMENT
ActionResultProto placementStateAction{};
placementStateAction.set_type(
net::eagle0::shardok::common::TUTORIAL_REINFORCEMENTS_ARRIVED);
placementStateAction.mutable_game_status()->set_state(
GameStatusProto::State::GameStatus_State_REINFORCEMENT_PLACEMENT);
*placementStateAction.mutable_game_status()->mutable_description() =
"Place your reinforcements!";
// Set current player to the reinforcement recipient
placementStateAction.mutable_next_player()->set_value(
pendingReinforcementPlacement_->playerId);
ApplyAndAddActionResult(placementStateAction);
return;
}
}
}
ApplyAndAddActionResults(
UpdateOpponentKnowledgeAction(settingsGetter).Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
NewRoundAction(gameState, settingsGetter).Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
StartPlayerTurnAction(gameState, UNCONTROLLED_PLAYER_ID, settingsGetter)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(PerformUndeadCommandsAction(gameState, settingsGetter)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(StartPlayerTurnAction(gameState, 0, settingsGetter)
.Execute(gameState, randomGenerator));
ResumeAfterReinforcementPlacement(randomGenerator);
} else {
const auto updateAction = UpdateGameStatusAction(
@@ -339,6 +344,27 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
}
}
void ShardokEngine::ResumeAfterReinforcementPlacement(
const std::shared_ptr<RandomGenerator> &randomGenerator) {
pendingReinforcementPlacement_ = std::nullopt;
ApplyAndAddActionResults(
UpdateOpponentKnowledgeAction(settingsGetter).Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
NewRoundAction(gameState, settingsGetter).Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
StartPlayerTurnAction(gameState, UNCONTROLLED_PLAYER_ID, settingsGetter)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(PerformUndeadCommandsAction(gameState, settingsGetter)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(StartPlayerTurnAction(gameState, 0, settingsGetter)
.Execute(gameState, randomGenerator));
}
void ShardokEngine::PostPlacementCommands(
const PlayerId player,
const vector<UnitPlacementInfo> &placementInfos,
@@ -347,8 +373,15 @@ void ShardokEngine::PostPlacementCommands(
throw ShardokClientErrorException("Posting for a player who's not the current player ID!");
}
const auto placementCommands =
availableCommandsFactory->GetPlayerSetupCommands(gameState, player);
const auto reinforcementPositions =
pendingReinforcementPlacement_.has_value()
? std::optional<std::vector<Coords>>(
pendingReinforcementPlacement_->startingPositions)
: std::nullopt;
const auto placementCommands = availableCommandsFactory->GetPlayerSetupCommands(
gameState,
player,
reinforcementPositions);
// first make sure they're all valid and there are no duplicates
for (size_t i = 0; i < placementInfos.size(); i++) {
@@ -388,6 +421,19 @@ void ShardokEngine::PostPlacementCommands(
}
}
// If we were in reinforcement placement, check if all units are now placed
if (pendingReinforcementPlacement_.has_value()) {
const auto reserveUnits =
ReserveUnitsForPlayer(gameState->units(), pendingReinforcementPlacement_->playerId);
if (reserveUnits.empty()) {
// All reinforcements placed — resume the turn flow
ResumeAfterReinforcementPlacement(randomGenerator);
return;
}
// Still have units to place — stay in REINFORCEMENT_PLACEMENT state
return;
}
const auto updateAction = UpdateGameStatusAction(
GetCurrentGameState(),
criticalTileCoords,
@@ -586,10 +632,16 @@ auto ShardokEngine::GetAvailableCommandProtos(const PlayerId playerId, const boo
auto ShardokEngine::UncachedGetAvailableCommands(
const PlayerId playerId,
const bool includeFollowUps) const -> CommandListSPtr {
const auto reinforcementPositions =
pendingReinforcementPlacement_.has_value()
? std::optional<std::vector<Coords>>(
pendingReinforcementPlacement_->startingPositions)
: std::nullopt;
return availableCommandsFactory->GetAvailableCommands(
gameState,
playerId,
/* includeFollowUps=*/includeFollowUps);
/* includeFollowUps=*/includeFollowUps,
reinforcementPositions);
}
void AddUnits(vector<net::eagle0::shardok::storage::ResolvedUnit> &to, const Units &from) {
@@ -621,6 +673,7 @@ void AddUnits(vector<net::eagle0::shardok::storage::ResolvedUnit> &to, const Uni
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
ru.set_status(
net::eagle0::shardok::storage::ResolvedUnit_UnitStatus_NEVER_ENTERED_UNIT);
break;
@@ -55,6 +55,7 @@ private:
const CoordsSet criticalTileCoords;
TutorialBattleController tutorialController_;
std::optional<ReinforcementPlacementInfo> pendingReinforcementPlacement_;
mutable CommandListSPtr cachedAvailableCommands{};
@@ -76,6 +77,7 @@ private:
}
void HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &randomGenerator);
void ResumeAfterReinforcementPlacement(const std::shared_ptr<RandomGenerator> &randomGenerator);
[[nodiscard]] auto UncachedGetAvailableCommands(PlayerId playerId, bool includeFollowUps) const
-> CommandListSPtr;
@@ -206,6 +208,12 @@ public:
tutorialController_ = TutorialBattleController(config);
}
/// Returns the pending reinforcement placement info, if any.
[[nodiscard]] auto GetPendingReinforcementPlacement() const
-> const std::optional<ReinforcementPlacementInfo> & {
return pendingReinforcementPlacement_;
}
static inline auto GameIsOver(const fb::GameStatus *status) -> bool {
return (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY);
}
@@ -90,6 +90,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/actions:end_player_setup_command",
"//src/main/cpp/net/eagle0/shardok/library/actions:place_hidden_unit_command",
"//src/main/cpp/net/eagle0/shardok/library/actions:place_unit_command",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/cpp/net/eagle0/shardok/library/util:player_utils",
@@ -66,11 +66,30 @@ auto PlayerSetupCommandFactory::AddAvailablePlaceAndHideUnitCommandsForOneUnit(
auto PlayerSetupCommandFactory::AddAvailablePlayerSetupCommands(
CommandList &existingCommands,
PlayerId playerId,
const GameStateW &gameState) const -> void {
const GameStateW &gameState,
const std::optional<std::vector<Coords>> &reinforcementPositions) const -> void {
if (playerId < 0) return;
if (gameState->status()->state() !=
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP)
const auto state = gameState->status()->state();
// Handle reinforcement placement phase
if (state == net::eagle0::shardok::storage::fb::GameStatus_::State_REINFORCEMENT_PLACEMENT) {
if (!reinforcementPositions.has_value()) return;
const auto unplacedUnits = ReserveUnitsForPlayer(gameState->units(), playerId);
if (unplacedUnits.empty()) return;
for (const auto &[unitId, unit] : unplacedUnits) {
for (const Coords &position : *reinforcementPositions) {
if (!gameState.GetOccupant(position)) {
existingCommands.push_back(std::make_shared<PlaceUnitCommand>(unit, position));
}
}
}
return;
}
if (state != net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) return;
const bool isDefender = PlayerIsDefender(gameState, playerId);
@@ -5,8 +5,12 @@
#ifndef EAGLE0_PLAYERSETUPCOMMANDFACTORY_HPP
#define EAGLE0_PLAYERSETUPCOMMANDFACTORY_HPP
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
@@ -29,7 +33,9 @@ public:
auto AddAvailablePlayerSetupCommands(
CommandList& existingCommands,
PlayerId playerId,
const GameStateW& gameState) const -> void;
const GameStateW& gameState,
const std::optional<std::vector<Coords>>& reinforcementPositions = std::nullopt) const
-> void;
};
} // namespace shardok
@@ -331,7 +331,7 @@ void MutatingApplyResult(
.size()
: changedUnit->battalion().size();
auto status = net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT;
auto status = changedUnit->status();
if (IsDestroyed(*changedUnit)) {
status =
changedUnit->has_attached_hero()
@@ -34,6 +34,7 @@ auto IsResolved(const net::eagle0::shardok::storage::fb::UnitStatus status) -> b
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT: return false;
}
@@ -18,6 +18,7 @@ using net::eagle0::shardok::common::GameStatus;
auto actorAfter = *currentState->units()->Get(actorId);
actorAfter.mutable_location() = target;
actorAfter.mutate_hidden(true);
actorAfter.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
MutatingBumpAllKnowledgeToMinimum(&actorAfter, minimumKnowledge);
ActionResult placeResult{};
@@ -16,6 +16,7 @@ auto PlaceUnitCommand::InternalExecute(
const std::shared_ptr<RandomGenerator>& /*generator*/) const -> vector<ActionResult> {
auto actorAfter = *actor;
actorAfter.mutable_location() = target;
actorAfter.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
ActionResult placeResult{};
placeResult.set_type(ActionType::PLACE_UNIT);
@@ -89,7 +89,9 @@ auto UpdateGameStatusAction::InternalExecute(
// If the game has already ended, just return that state
if (GameIsOver(gameState->status())) { return results; }
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP ||
gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_REINFORCEMENT_PLACEMENT) {
return results;
}
@@ -25,6 +25,7 @@ auto ReinforceCommand::InternalExecute(
const Unit* joiningUnit = currentState->units()->Get(joiningUnitId);
auto joinerAfter = *joiningUnit;
joinerAfter.mutable_location() = location;
joinerAfter.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
ActionResult placeResult{};
placeResult.set_type(ActionType::REINFORCED);
@@ -176,7 +176,10 @@ auto SetupInitialGameState(
modifiedUnit.mutable_battalion().mutate_base_morale(baseMorale);
modifiedUnit.mutable_battalion().mutate_morale(baseMorale);
modifiedUnit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT);
if (modifiedUnit.status() !=
net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT) {
modifiedUnit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT);
}
unitsVec.push_back(modifiedUnit);
}
@@ -9,6 +9,7 @@ cc_library(
deps = [
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:action_result_flatbuffer_helpers",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_cc_proto",
@@ -34,10 +34,10 @@ TutorialBattleController::TutorialBattleController(const TutorialBattleConfigPro
auto TutorialBattleController::CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) -> std::vector<ActionResult> {
const std::shared_ptr<RandomGenerator>& randomGenerator) -> TutorialEventResults {
if (!enabled_) { return {}; }
std::vector<ActionResult> allResults;
TutorialEventResults combined;
// Check events in config order
for (const auto& event : config_.events()) {
@@ -50,12 +50,20 @@ auto TutorialBattleController::CheckAndExecuteEvents(
firedEventIds_.insert(event.event_id());
// Execute the action
auto results = ExecuteAction(event.action(), state, settings, randomGenerator);
allResults.insert(allResults.end(), results.begin(), results.end());
auto eventResults = ExecuteAction(event.action(), state, settings, randomGenerator);
combined.actionResults.insert(
combined.actionResults.end(),
eventResults.actionResults.begin(),
eventResults.actionResults.end());
// Only one reinforcement placement can be pending at a time
if (eventResults.reinforcementPlacement.has_value()) {
combined.reinforcementPlacement = eventResults.reinforcementPlacement;
}
}
}
return allResults;
return combined;
}
auto TutorialBattleController::EvaluateTrigger(
@@ -90,15 +98,17 @@ auto TutorialBattleController::EvaluateTrigger(
auto TutorialBattleController::ExecuteAction(
const TutorialActionProto& action,
[[maybe_unused]] const GameStateW& state,
const GameStateW& state,
[[maybe_unused]] const SettingsGetter& settings,
[[maybe_unused]] const std::shared_ptr<RandomGenerator>& randomGenerator) const
-> std::vector<ActionResult> {
-> TutorialEventResults {
switch (action.action_type_case()) {
case TutorialActionProto::kFlee: return ExecuteFleeAction(action.flee(), state);
case TutorialActionProto::kFlee:
return {.actionResults = ExecuteFleeAction(action.flee(), state),
.reinforcementPlacement = std::nullopt};
case TutorialActionProto::kReinforcements:
return ExecuteReinforcementsAction(action.reinforcements());
return ExecuteReinforcementsAction(action.reinforcements(), state);
case TutorialActionProto::ACTION_TYPE_NOT_SET:
default: return {};
@@ -116,7 +126,8 @@ auto TutorialBattleController::CountLostUnits(const GameStateW& state, const Pla
if (status != UnitStatusFB::UnitStatus_NORMAL_UNIT &&
status != UnitStatusFB::UnitStatus_RESERVE_UNIT &&
status != UnitStatusFB::UnitStatus_NEVER_ENTERED_UNIT &&
status != UnitStatusFB::UnitStatus_RESERVED_SLOT) {
status != UnitStatusFB::UnitStatus_RESERVED_SLOT &&
status != UnitStatusFB::UnitStatus_PENDING_REINFORCEMENT) {
count++;
}
}
@@ -192,16 +203,25 @@ auto TutorialBattleController::ExecuteFleeAction(
results.push_back(fleeNotification);
// Determine which units should flee
std::set<int32_t> specificUnits(fleeAction.unit_ids().begin(), fleeAction.unit_ids().end());
const bool fleeAllUnits = specificUnits.empty();
std::set<int32_t> specificUnitIds(fleeAction.unit_ids().begin(), fleeAction.unit_ids().end());
std::set<int32_t> specificHeroIds(
fleeAction.eagle_hero_ids().begin(),
fleeAction.eagle_hero_ids().end());
const bool fleeAllUnits = specificUnitIds.empty() && specificHeroIds.empty();
// Find all matching units that are still active and make them flee
for (const auto* unit : *state->units()) {
if (unit->player_id() != fleeingPlayerId) { continue; }
if (unit->status() != UnitStatusFB::UnitStatus_NORMAL_UNIT) { continue; }
// Check if this is a specific unit to flee, or if we're fleeing all
if (!fleeAllUnits && !specificUnits.contains(unit->unit_id())) { continue; }
// Check if this unit should flee
if (!fleeAllUnits) {
const bool matchesUnitId = specificUnitIds.contains(unit->unit_id());
const bool matchesHeroId =
unit->has_attached_hero() &&
specificHeroIds.contains(unit->attached_hero().eagle_hero_id());
if (!matchesUnitId && !matchesHeroId) { continue; }
}
// Create flee result for this unit (100% success - guaranteed flee)
ActionResult fleeResult{};
@@ -220,17 +240,44 @@ auto TutorialBattleController::ExecuteFleeAction(
}
auto TutorialBattleController::ExecuteReinforcementsAction(
const ReinforcementsActionProto& reinforcementsAction) const -> std::vector<ActionResult> {
std::vector<ActionResult> results;
const ReinforcementsActionProto& reinforcementsAction,
const GameStateW& state) const -> TutorialEventResults {
TutorialEventResults eventResults;
// Add reinforcements arrival notification if there are units
if (!reinforcementsAction.units().empty()) {
ActionResult reinforcementsResult{};
reinforcementsResult.set_type(ActionType::TUTORIAL_REINFORCEMENTS_ARRIVED);
results.push_back(reinforcementsResult);
if (reinforcementsAction.units().empty()) { return eventResults; }
const PlayerId playerId = reinforcementsAction.player_id();
// Look up starting positions from the hex map's attacker starting positions
using Coords = net::eagle0::shardok::storage::fb::Coords;
std::vector<Coords> availablePositions;
const int startingPosGroupIndex = reinforcementsAction.attacker_starting_position_index();
const auto* attackerPositions = state->hex_map()->attacker_starting_positions();
if (startingPosGroupIndex >= 0 &&
startingPosGroupIndex < static_cast<int>(attackerPositions->size())) {
for (const auto* pos : *attackerPositions->Get(startingPosGroupIndex)->positions()) {
if (!state.GetOccupant(*pos)) { availablePositions.push_back(*pos); }
}
}
return results;
// Place PENDING_REINFORCEMENT units directly at available positions as NORMAL_UNIT
ActionResult reinforcementsResult{};
reinforcementsResult.set_type(ActionType::TUTORIAL_REINFORCEMENTS_ARRIVED);
reinforcementsResult.mutable_player()->set_value(playerId);
size_t posIndex = 0;
for (const auto* unit : *state->units()) {
if (unit->status() != UnitStatusFB::UnitStatus_PENDING_REINFORCEMENT) { continue; }
if (posIndex >= availablePositions.size()) { break; }
net::eagle0::shardok::storage::fb::Unit changedUnit = *unit;
changedUnit.mutate_status(UnitStatusFB::UnitStatus_NORMAL_UNIT);
changedUnit.mutable_location() = availablePositions[posIndex++];
AddChangedUnit(reinforcementsResult, changedUnit);
}
eventResults.actionResults.push_back(reinforcementsResult);
return eventResults;
}
} // namespace shardok
@@ -9,12 +9,14 @@
#ifndef TutorialBattleController_hpp
#define TutorialBattleController_hpp
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/common/tutorial_battle_config.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/storage/action_result.pb.h"
@@ -27,6 +29,19 @@ using TutorialEventProto = net::eagle0::common::TutorialEvent;
using TutorialTriggerProto = net::eagle0::common::TutorialTrigger;
using TutorialActionProto = net::eagle0::common::TutorialAction;
/// Info about a pending reinforcement placement phase.
struct ReinforcementPlacementInfo {
PlayerId playerId;
std::vector<Coords> startingPositions;
};
/// Results from tutorial event processing, including action results and any pending
/// reinforcement placement.
struct TutorialEventResults {
std::vector<ActionResult> actionResults;
std::optional<ReinforcementPlacementInfo> reinforcementPlacement;
};
/// Controller for scripted tutorial battles with event-driven triggers and actions.
/// Events are checked at the end of each round and fire in config order.
/// Each event fires at most once (tracked by event_id).
@@ -47,7 +62,7 @@ public:
[[nodiscard]] auto CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) -> std::vector<ActionResult>;
const std::shared_ptr<RandomGenerator>& randomGenerator) -> TutorialEventResults;
/// Gets the set of fired event IDs (for testing/debugging).
[[nodiscard]] auto GetFiredEventIds() const -> const std::set<std::string>& {
@@ -68,8 +83,7 @@ private:
const TutorialActionProto& action,
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) const
-> std::vector<ActionResult>;
const std::shared_ptr<RandomGenerator>& randomGenerator) const -> TutorialEventResults;
// Helper methods for trigger evaluation
@@ -91,10 +105,10 @@ private:
const net::eagle0::common::FleeAction& fleeAction,
const GameStateW& state) const -> std::vector<ActionResult>;
/// Executes a reinforcements action.
/// Executes a reinforcements action, placing units directly at available starting positions.
[[nodiscard]] auto ExecuteReinforcementsAction(
const net::eagle0::common::ReinforcementsAction& reinforcementsAction) const
-> std::vector<ActionResult>;
const net::eagle0::common::ReinforcementsAction& reinforcementsAction,
const GameStateW& state) const -> TutorialEventResults;
};
} // namespace shardok
@@ -331,7 +331,8 @@ auto PlacedUnitsForPlayer(const Units *units, const PlayerId pid)
std::map<UnitId, const Unit *> placedUnits{};
for (const Unit *unit : *units) {
if (unit->player_id() == pid &&
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT)
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT &&
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT)
placedUnits[unit->unit_id()] = unit;
}
@@ -73,6 +73,9 @@ void DumpGameStateToFile(const GameStateW& gameState, const std::string& gameId)
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
out << "RETREATED_UNIT\n";
break;
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
out << "PENDING_REINFORCEMENT\n";
break;
default: out << "OTHER (" << (int)unit->status() << ")\n";
}
out << " Remaining Action Points: " << (int)unit->remaining_action_points() << "\n";
@@ -49,7 +49,8 @@ auto GameStateFilteredForPlayer(
case net::eagle0::shardok::storage::fb::UnitStatus_FLED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
throw ShardokInternalErrorException("Unknown unit status");
@@ -100,6 +101,7 @@ auto GameStateFilteredForPlayer(
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT: break;
}
}
@@ -12,6 +12,7 @@ cc_library(
deps = [
":server_configuration",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/common:unit_conversions",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/controller",
"//src/main/cpp/net/eagle0/shardok/library:engine",
@@ -222,20 +222,14 @@ auto EagleInterfaceImpl::PostCommand(
request->game_setup_info().known_result_count(),
response);
} catch (ShardokClientErrorException &e) {
std::cerr << "Bad request from client!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
std::cerr << "Bad request from client: " << e.what() << std::endl;
return Status(StatusCode::INVALID_ARGUMENT, e.what());
} catch (ShardokInternalErrorException &e) {
std::cerr << "ShardokInternalErrorException thrown!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
std::cerr << "Internal error: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
} catch (std::exception &e) {
std::cerr << "Unknown exception in client->PostCommand" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
std::cerr << "Unknown exception: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
}
return Status::OK;
@@ -274,20 +268,14 @@ auto EagleInterfaceImpl::PostPlacementCommands(
request->game_setup_info().known_result_count(),
response);
} catch (ShardokClientErrorException &e) {
std::cerr << "Bad request from client!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
std::cerr << "Bad request from client: " << e.what() << std::endl;
return Status(StatusCode::INVALID_ARGUMENT, e.what());
} catch (ShardokInternalErrorException &e) {
std::cerr << "ShardokInternalErrorException thrown!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
std::cerr << "Internal error: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
} catch (std::exception &e) {
std::cerr << "Unknown exception in client->PostCommand" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
std::cerr << "Unknown exception: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
}
return Status::OK;
@@ -13,6 +13,7 @@
#include <utility>
#include "ServerConfiguration.hpp"
#include "src/main/cpp/net/eagle0/common/UnitConversions.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
@@ -151,6 +152,34 @@ auto SetUpController(
unplacedUnits.push_back(up);
}
// Add reinforcement units from tutorial config as PENDING_REINFORCEMENT.
// These units exist in the game state from the start but are invisible and inactive
// until the TutorialBattleController activates them at the triggered round.
if (tutorialConfig.enabled()) {
vector<PlayerId> allPlayerIds;
for (size_t i = 0; i < playerInfos.size(); i++) {
allPlayerIds.push_back(static_cast<PlayerId>(i));
}
for (const auto &event : tutorialConfig.events()) {
if (!event.has_action() ||
event.action().action_type_case() !=
net::eagle0::common::TutorialAction::kReinforcements) {
continue;
}
const auto &reinforcements = event.action().reinforcements();
const PlayerId playerId = reinforcements.player_id();
for (const auto &commonUnit : reinforcements.units()) {
Unit unit = ConvertUnit(commonUnit, playerId, allPlayerIds);
unit.mutate_unit_id(nextUnitId++);
unit.mutate_status(
net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT);
unplacedUnits.push_back(unit);
}
}
}
unique_ptr<ShardokEngine> engine = std::make_unique<ShardokEngine>(
gameSettings,
std::move(unplacedUnits),
@@ -29,6 +29,7 @@ ExportedObj/
# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
Assets/GeneratedProtos.meta
# Unity3D Generated File On Crash Reports
sysinfo.txt
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@ MonoBehaviour:
m_DefaultGroup: 4458439bbfafa478b905417eff3c5741
m_currentHash:
serializedVersion: 2
Hash: 00000000000000000000000000000000
Hash: 0e8900f37a8b7b1c88049de57e371339
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 1
m_CatalogRequestsTimeout: 0
@@ -108,8 +108,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TextMeshProUGUI connectionStatusText;
[Header("Connection Panel Environment")]
[Tooltip("Environment dropdown in connection panel (fallback if lobby unreachable)")]
public TMP_Dropdown connectionEnvironmentDropdown;
[Tooltip("Connect to QA button in connection panel (editor-only)")]
public Button connectToQAButton;
public GameObject connectionPanel;
public GameObject connectionBackgroundLayer;
@@ -121,7 +121,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button logoutButton;
public Button customBattleButton;
public Button cancelCustomBattleButton;
public TMP_Dropdown lobbyEnvironmentDropdown;
public Button lobbyConnectToQAButton;
public TextMeshProUGUI lobbyUserText;
public TextMeshProUGUI lobbyStatusText;
@@ -150,6 +150,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private bool _isLocalQAConnection = false;
private List<StoredAccountButton> _storedAccountButtons = new();
private bool _exitedFullscreenForNewUser = false; // Track if we exited fullscreen for login
private FullScreenMode _previousFullScreenMode; // Store mode to restore after login
@@ -168,7 +169,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
/// Returns the connected environment name ("prod." or "qa.") or null if not connected.
/// </summary>
public string ConnectedEnvironmentName =>
_connectedEnvironmentIndex >= 0 &&
_isLocalQAConnection ? "QA (local)"
: _connectedEnvironmentIndex >= 0 &&
_connectedEnvironmentIndex < EnvironmentDisplayNames.Count
? EnvironmentDisplayNames[_connectedEnvironmentIndex]
: null;
@@ -218,8 +220,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize connection panel environment dropdown
SetupConnectionEnvironmentDropdown();
// Initialize connection panel QA button (editor-only)
SetupConnectToQAButton();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
@@ -235,27 +237,17 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
TryRestoreSession();
}
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
private void SetupConnectToQAButton() {
if (connectToQAButton == null) return;
// Only show environment dropdown in Unity Editor; always use prod in builds
// Only show QA button in Unity Editor; always use prod in builds
if (!Application.isEditor) {
connectionEnvironmentDropdown.gameObject.SetActive(false);
connectToQAButton.gameObject.SetActive(false);
PlayerPrefs.SetInt(EnvironmentKey, 0); // Force prod
return;
}
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
}
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
// Just save the preference - it will be used on next connection attempt
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
Debug.Log(
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
connectToQAButton.onClick.AddListener(OnConnectToQAClicked);
}
private void SetupOAuthUI() {
@@ -295,15 +287,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Sync connection environment dropdown with saved preference
if (connectionEnvironmentDropdown != null) {
connectionEnvironmentDropdown.onValueChanged.RemoveListener(
OnConnectionEnvironmentChanged);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(
OnConnectionEnvironmentChanged);
}
// Refresh stored account buttons
RefreshStoredAccountButtons();
@@ -416,35 +399,46 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
tutorialButton.gameObject.SetActive(TokenStorage.DisplayName == "nolen");
tutorialButton.onClick.AddListener(CreateTutorialGame);
// Set up environment dropdown (only in Unity Editor)
if (!Application.isEditor) {
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
// Set up QA button (only in Unity Editor)
if (lobbyConnectToQAButton != null) {
if (!Application.isEditor) {
lobbyConnectToQAButton.gameObject.SetActive(false);
} else {
lobbyConnectToQAButton.onClick.AddListener(OnLobbyConnectToQAClicked);
}
}
}
private void UpdateLobbyStatusDisplays() {
// Update environment dropdown selection
if (_connectedEnvironmentIndex >= 0) {
// Temporarily remove listener to avoid triggering reconnect
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
// Show current user from OAuth
lobbyUserText.text = TokenStorage.DisplayName ?? "";
lobbyUserText.text = _isLocalQAConnection ? "editor" : TokenStorage.DisplayName ?? "";
}
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
if (newEnvironmentIndex == _connectedEnvironmentIndex) return;
private void OnConnectToQAClicked() {
Debug.Log("[ConnectionHandler] Connect to QA clicked");
_isLocalQAConnection = true;
Debug.Log(
$"[ConnectionHandler] Switching environment from {ConnectedEnvironmentName} to {EnvironmentDisplayNames[newEnvironmentIndex]}");
SetLobbyActive(true);
connectionCanvas.enabled = true;
_createLocalQAConnection();
if (_persistentClientConnection == null) {
Debug.LogError("[ConnectionHandler] QA connection creation failed");
return;
}
try {
RequestMaps();
StartListeningForLobbyUpdates();
} catch (Exception e) {
MainQueue.Q.Enqueue(() => { errorHandler.Add(e); });
}
}
private void OnLobbyConnectToQAClicked() {
if (_isLocalQAConnection) return;
Debug.Log("[ConnectionHandler] Lobby: switching to QA (local)");
// Disconnect from current environment
_persistentClientConnection?.Dispose();
@@ -455,23 +449,54 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleConnection = null;
_httpClient = null;
// Update environment index and reconnect
_connectedEnvironmentIndex = newEnvironmentIndex;
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
_isLocalQAConnection = true;
// Clear current game lists while reconnecting
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
// Show loading indicator
if (lobbyStatusText != null) { lobbyStatusText.text = "Switching environment..."; }
if (lobbyStatusText != null) { lobbyStatusText.text = "Connecting to QA..."; }
// Reconnect to new environment
_createConnection();
_createLocalQAConnection();
RequestMaps();
StartListeningForLobbyUpdates();
}
private void _createLocalQAConnection() {
// Dispose existing connections before creating new ones
_httpClient?.Dispose();
_persistentClientConnection?.Dispose();
eagleConnection?.Dispose();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = new CancellationTokenSource();
eagleConnection = EagleConnection.CreateInsecure("http://localhost:40032");
// Plain HttpClient with no auth for local QA — ResourceFetcher still uses prod assets
_httpClient = new HttpClient();
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
EagleConnection.EagleCancellationToken);
_persistentClientConnection.OnChannelDead = () => {
MainQueue.Q.Enqueue(() => {
Debug.Log("[ConnectionHandler] QA channel dead, recreating connection");
_createLocalQAConnection();
});
};
_persistentClientConnection.OnCommandError = (errorMessage) => {
MainQueue.Q.Enqueue(() => {
Debug.LogError($"[ConnectionHandler] Command rejected by server: {errorMessage}");
});
};
_persistentClientConnection.Connect();
PersistentClientConnection.Current = _persistentClientConnection;
errorHandler.PersistentClientConnection = _persistentClientConnection;
}
public async void OnLogoutClicked() {
Debug.Log("[ConnectionHandler] Logout button clicked");
@@ -489,6 +514,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_httpClient = null;
_cancellationTokenSource = null;
_connectedEnvironmentIndex = -1;
_isLocalQAConnection = false;
// Return to connection screen
gameSelectionPanel.SetActive(false);
@@ -667,6 +693,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleConnection = null;
_connectedEnvironmentIndex = -1;
_isLocalQAConnection = false;
}
public void OnResolutionChanged(int dropdownIndex) {
@@ -705,7 +732,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Set up running games table
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (var runningGame in lobbyResponse.RunningGames) {
foreach (var runningGame in lobbyResponse.RunningGames
.OrderBy(g => g.CreatedTimestampMillis)
.ThenBy(g => g.GameId)) {
var listItem = Instantiate(
runningGamesListItemPrefab,
runningGamesListArea.transform,
@@ -743,8 +772,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var listItem = Instantiate(
availableGamesListItemPrefab,
availableGamesListArea.transform,
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
false);
var availableGameItem = listItem.GetComponent<AvailableGameItem>();
availableGameItem.SetAvailableNewGame(
availableGame.GameId,
@@ -945,12 +973,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void CreateGame(string leaderNameTextId, int humanPlayerCount, int totalPlayerCount) {
// Track multiplayer status for tutorial system
TutorialManager.IsMultiplayerGame = humanPlayerCount > 1;
TutorialManager.IsTutorialGame = false;
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
}
public void CreateTutorialGame() {
// Tutorial is always single-player
TutorialManager.IsMultiplayerGame = false;
TutorialManager.IsTutorialGame = true;
// Eagle will set up the hardcoded tutorial scenario when isTutorial is true
var game = _internalCreateGame(
desiredLeaderTextId: "",
@@ -10,7 +10,7 @@ namespace eagle {
[Header("Snowflake Settings")]
public Texture2D snowflakeTexture;
public Material particleMaterial; // Assign in prefab to ensure shader is in build
public float snowEmitRate = 40f; // More particles
public float snowEmitRate = 45f;
public float snowFallSpeed = 50f;
public float snowLifetime = 4f; // Longer lifetime
public float snowSize = 10f; // Slightly larger
@@ -54,7 +54,7 @@ namespace eagle {
main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f);
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 300; // More particles for full coverage
main.maxParticles = 900;
var emission = _snowSystem.emission;
emission.enabled = true;
@@ -350,6 +350,12 @@ namespace eagle {
"CommandPanel",
commandPanel.GetComponent<RectTransform>());
}
// Register Go to Battle button for dialogue highlight
if (TutorialManager.Instance?.TargetRegistry != null && goToBattleButton != null) {
TutorialManager.Instance.TargetRegistry.RegisterTarget(
"GoToBattleButton",
goToBattleButton.GetComponent<RectTransform>());
}
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
@@ -580,6 +586,12 @@ namespace eagle {
return;
}
// On first model, sync IsTutorialGame from the server-reported GameType.
// This covers joining an existing game (where ConnectionHandler doesn't know the type).
if (oldModel == null) {
TutorialManager.IsTutorialGame = Model.GameType == GameType.Tutorial;
}
// Start onboarding after first model update (UI panels are now populated)
if (oldModel == null && TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
@@ -760,6 +772,9 @@ namespace eagle {
}
public void GoToBattle() {
// End any active dialogue before transitioning to battle
TutorialManager.Instance?.DialogueManager?.EndDialogue();
// Hide the button now, so if we get an exception below we can at least recover
goToBattleButton.gameObject.SetActive(false);
@@ -59,6 +59,8 @@ namespace eagle {
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
public List<ChronicleEntry> ChronicleEntries { get; }
GameType GameType { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
@@ -201,6 +203,8 @@ namespace eagle {
public List<ChronicleEntry> ChronicleEntries { get; set; }
public GameType GameType => GsView.GameType;
public List<AvailableCommand> AvailableCommandsForProvince(ProvinceId pid) {
if (AvailableCommandsByProvince.TryGetValue(pid, out var cmds)) {
return cmds.Commands.ToList();
@@ -298,7 +302,13 @@ namespace eagle {
kv => kv.Value.ImagePath),
battalionNames: _currentModel.BattalionNames.ToDictionary(
kv => kv.Key,
kv => kv.Value));
kv => kv.Value),
heroFallbackLookup: hid => {
if (_currentModel.Heroes.TryGetValue(hid, out var hero)) {
return (hero.NameTextId, hero.ImagePath);
}
return null;
});
return shardokModel;
}
@@ -932,6 +942,10 @@ namespace eagle {
}
foreach (var ce in entry.UpdatedChronicleEntries) { UpdateChronicleEntry(ce); }
if (entry.NewGameType != GameType.UnknownGameType) {
_currentModel.GsView.GameType = entry.NewGameType;
}
}
private void HandleNewHistoryEntry(ActionResultView arv) {
@@ -50,7 +50,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::eagle.BlizzardEffect
snowflakeTexture: {fileID: 2800000, guid: 9a5b4e8c3d2f6b0a7c1e9d5f8a3b2c4d, type: 3}
particleMaterial: {fileID: 2100000, guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6, type: 2}
snowEmitRate: 15
snowEmitRate: 45
snowFallSpeed: 40
snowLifetime: 3
snowSize: 8
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using ProvinceId = System.Int32;
using FactionId = System.Int32;
@@ -12,6 +14,7 @@ namespace eagle {
public EagleGameController eagleGameController;
public ProvinceColorManager provinceColorManager;
public ProvinceBorderManager provinceBorderManager;
public ProvinceWeatherController weatherController;
public ProvinceFestivalController festivalController;
public ProvinceEpidemicController epidemicController;
@@ -126,14 +129,33 @@ namespace eagle {
public ProvinceIDLoader provinceIDLoader;
private readonly Color _selectionColor = Color.grey;
private readonly Color _targetColor = Color.red;
private readonly Color _commandAvailableColor = Color.blue;
private readonly Color _hoverColor = new Color(0, 0, 0.5f, 0.5f);
private const float SelectedBorderMin = 2f, SelectedBorderMax = 6f,
SelectedBorderCycle = 1.0f;
private static readonly Color OverrideStrobeColor = Color.black;
private static readonly Color OverrideBorderColor = new Color(0.05f, 0.05f, 0.05f, 0.95f);
private const float OverrideBorderMin = 3f, OverrideBorderMax = 8f,
OverrideBorderCycle = 0.25f;
private static readonly Color TargetedStrobeColor = new Color(0.55f, 0.05f, 0.15f);
private static readonly Color TargetedBorderColor = new Color(0.55f, 0.05f, 0.15f, 0.95f);
private const float TargetedBorderMin = 3f, TargetedBorderMax = 8f,
TargetedBorderCycle = 0.25f;
private const float CommandBorderMin = 1.5f, CommandBorderMax = 5f,
CommandBorderCycle = 0.5f;
private static readonly Color CommandBorderColor = new Color(0.3f, 0.3f, 1f, 0.85f);
private readonly HashSet<ProvinceId> _activeHighlights = new();
private byte[] _mapIdBytes;
ProvinceId? _previouslyHoveredProvinceId;
private static readonly Color BlizzardTint = new Color(0.82f, 0.88f, 0.95f);
private Color ColorForProvince(ProvinceView province) {
var color =
PlayerColors.LightPlayerColor(province.RulingFactionId.GetValueOrDefault(0));
@@ -143,9 +165,31 @@ namespace eagle {
province.RulingFactionId.GetValueOrDefault(0));
}
if (HasBlizzardEvent(province)) { color *= BlizzardTint; }
return color;
}
private static float Luminance(Color c) {
return 0.299f * c.r + 0.587f * c.g + 0.114f * c.b;
}
private static (Color strobe, Color border) ContrastingHighlightColors(Color baseColor) {
if (Luminance(baseColor) > 0.5f) {
return (Color.black, new Color(0.05f, 0.05f, 0.05f, 0.95f));
}
return (Color.white, new Color(1f, 1f, 1f, 0.95f));
}
private static bool HasBlizzardEvent(ProvinceView province) {
foreach (var evt in province.KnownEvents) {
if (evt.SealedValueCase == ProvinceEvent.SealedValueOneofCase.BlizzardEvent) {
return true;
}
}
return false;
}
void Start() {
_mapIdBytes = provinceIDLoader.DecompressedBytes;
Enabled = true;
@@ -183,6 +227,12 @@ namespace eagle {
void Update() {
if (Model == null || Model.Provinces == null || Model.Provinces.Count == 0) { return; }
// Clear previous frame's border highlights
foreach (var pid in _activeHighlights) {
provinceBorderManager.ClearProvinceBorder(pid);
}
_activeHighlights.Clear();
var maybeHoveredProvinceId = ProvinceFromPoint(Input.mousePosition);
if (Model != null && maybeHoveredProvinceId != _previouslyHoveredProvinceId) {
_previouslyHoveredProvinceId = maybeHoveredProvinceId;
@@ -197,18 +247,47 @@ namespace eagle {
SetDefaultProvinceColor(commandProvince);
}
provinceBorderManager.SetHighlightGroup(OverrideTargetedProvinces);
foreach (var pid in OverrideTargetedProvinces) {
FlashProvince(pid, _targetColor, 0.25f);
FlashProvince(pid, OverrideStrobeColor, OverrideBorderCycle);
HighlightProvinceBorder(
pid,
OverrideBorderColor,
OverrideBorderMin,
OverrideBorderMax,
OverrideBorderCycle);
}
} else {
provinceBorderManager.ClearHighlightGroup();
if (SelectedProvinceId is ProvinceId sPid) {
FlashProvince(sPid, _selectionColor, 1.0f);
var sBase = ColorForProvince(Model.Provinces[sPid]);
var (sStrobe, sBorder) = ContrastingHighlightColors(sBase);
FlashProvince(sPid, sStrobe, SelectedBorderCycle);
HighlightProvinceBorder(
sPid,
sBorder,
SelectedBorderMin,
SelectedBorderMax,
SelectedBorderCycle);
}
foreach (ProvinceId tPid in TargetedProvinces) {
FlashProvince(tPid, _targetColor, 0.25f);
FlashProvince(tPid, TargetedStrobeColor, TargetedBorderCycle);
HighlightProvinceBorder(
tPid,
TargetedBorderColor,
TargetedBorderMin,
TargetedBorderMax,
TargetedBorderCycle);
}
foreach (var commandProvince in ProvincesWithCommands) {
FlashProvince(commandProvince, _commandAvailableColor, 0.5f);
FlashProvince(commandProvince, _commandAvailableColor, CommandBorderCycle);
HighlightProvinceBorder(
commandProvince,
CommandBorderColor,
CommandBorderMin,
CommandBorderMax,
CommandBorderCycle);
}
}
}
@@ -222,6 +301,18 @@ namespace eagle {
Mathf.PingPong(Time.time, duration)));
}
private void HighlightProvinceBorder(
ProvinceId provinceId,
Color borderColor,
float minWidth,
float maxWidth,
float cycleDuration) {
float t = Mathf.PingPong(Time.time, cycleDuration) / cycleDuration;
float width = Mathf.Lerp(minWidth, maxWidth, t);
provinceBorderManager.SetProvinceBorder(provinceId, borderColor, width);
_activeHighlights.Add(provinceId);
}
public void OnLeftClick(Vector2 position) {
SelectedProvinceId = ProvinceFromPoint(position);
}
@@ -235,6 +326,81 @@ namespace eagle {
targetedProvincesChangedCallback.Invoke(TargetedProvinces);
}
// ========== Province Highlight Proxy ==========
private readonly Dictionary<int, Vector4> _provinceBoundsCache =
new Dictionary<int, Vector4>();
private RectTransform _provinceHighlightProxy;
/// <summary>
/// Creates a RectTransform child of the map panel that overlays the given province.
/// Used by the dialogue system to highlight provinces on the strategic map.
/// Returns null if the province is not found.
/// </summary>
public RectTransform GetProvinceHighlightProxy(string provinceName) {
if (Model == null) return null;
var province = Model.Provinces.Values.FirstOrDefault(p => p.Name == provinceName);
if (province == null) return null;
if (!_provinceBoundsCache.TryGetValue(province.Id, out var bounds)) {
bounds = ComputeProvinceBounds(province.Id);
_provinceBoundsCache[province.Id] = bounds;
}
ClearProvinceHighlightProxy();
int w = provinceIDLoader.mapWidth;
int h = provinceIDLoader.mapHeight;
var panelPositions = gameObject.GetComponentInParent<PanelPositions>();
var proxyGO = new GameObject("DialogueProvinceHighlight");
proxyGO.AddComponent<CanvasRenderer>();
proxyGO.transform.SetParent(panelPositions.mapPanelTransform, false);
_provinceHighlightProxy = proxyGO.AddComponent<RectTransform>();
// Anchors in RectTransform space: (0,0) = bottom-left, (1,1) = top-right.
// Province ID byte array: y=0 = bottom of map (matches RectTransform origin).
_provinceHighlightProxy.anchorMin =
new Vector2(bounds.x / w, bounds.y / h); // (minX, minY)
_provinceHighlightProxy.anchorMax =
new Vector2(bounds.z / w, bounds.w / h); // (maxX, maxY)
_provinceHighlightProxy.offsetMin = Vector2.zero;
_provinceHighlightProxy.offsetMax = Vector2.zero;
return _provinceHighlightProxy;
}
public void ClearProvinceHighlightProxy() {
if (_provinceHighlightProxy != null) {
Destroy(_provinceHighlightProxy.gameObject);
_provinceHighlightProxy = null;
}
}
/// <summary>
/// Scans the province ID bytes to find the bounding box of a province.
/// Returns Vector4(minX, minY, maxX, maxY) in map pixel coordinates.
/// </summary>
private Vector4 ComputeProvinceBounds(int provinceId) {
int w = provinceIDLoader.mapWidth;
int h = provinceIDLoader.mapHeight;
int minX = w, minY = h, maxX = 0, maxY = 0;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
if (_mapIdBytes[y * w + x] == provinceId) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
return new Vector4(minX, minY, maxX, maxY);
}
void SetUpCenterText(ProvinceId? maybeProvinceId, Color color) {
provinceNameText.color = color;
provinceOwnerText.color = color;
@@ -23,10 +23,30 @@ Material:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BorderColorLookup:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BorderDistanceMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BorderWidthLookup:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ColorLookup:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _CrossProvinceIDMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _HighlightGroupLookup:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
@@ -43,6 +63,7 @@ Material:
m_Floats:
- _ColorMask: 15
- _Cutoff: 0.308
- _DefaultBorderWidth: 0.5
- _OceanWaveSpeed: 2
- _OceanWaveStrength: 0.0337
- _Stencil: 0
@@ -51,6 +72,7 @@ Material:
- _StencilReadMask: 255
- _StencilWriteMask: 255
m_Colors:
- _DefaultBorderColor: {r: 0.15, g: 0.1, b: 0.05, a: 0.8}
- _OceanColor: {r: 0.4, g: 0.54901963, b: 0.8, a: 1}
- _OceanScrollSpeed: {r: 0.06, g: 0.03, b: 0, a: 0}
m_BuildTextureStacks: []
@@ -0,0 +1,160 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Generates a distance-to-border texture from the province ID map at startup.
/// Each pixel stores the Euclidean distance (Chamfer approximation, in texels) to
/// the nearest province boundary, clamped to 255. The shader uses this to render
/// variable-width borders with a single texture sample per fragment.
///
/// Also generates a cross-province ID map: for each pixel, the province ID on the
/// other side of the nearest border. The shader uses this to suppress highlight
/// borders between provinces in the same highlight group.
/// </summary>
public class ProvinceBorderDistanceGenerator : MonoBehaviour {
public ProvinceIDLoader provinceIDLoader;
public Material provinceMapMaterial;
private Texture2D _distanceTexture;
private Texture2D _crossProvinceTexture;
void Start() {
var mapBytes = provinceIDLoader.DecompressedBytes;
int w = provinceIDLoader.mapWidth;
int h = provinceIDLoader.mapHeight;
var (dist, crossIds) = ComputeDistanceMap(mapBytes, w, h);
_distanceTexture = new Texture2D(w, h, TextureFormat.R8, false);
_distanceTexture.filterMode = FilterMode.Bilinear;
_distanceTexture.wrapMode = TextureWrapMode.Clamp;
_distanceTexture.LoadRawTextureData(dist);
_distanceTexture.Apply();
_crossProvinceTexture = new Texture2D(w, h, TextureFormat.R8, false);
_crossProvinceTexture.filterMode = FilterMode.Point;
_crossProvinceTexture.wrapMode = TextureWrapMode.Clamp;
_crossProvinceTexture.LoadRawTextureData(crossIds);
_crossProvinceTexture.Apply();
provinceMapMaterial.SetTexture("_BorderDistanceMap", _distanceTexture);
provinceMapMaterial.SetTexture("_CrossProvinceIDMap", _crossProvinceTexture);
}
private static (byte[] dist, byte[] crossIds) ComputeDistanceMap(byte[] ids, int w, int h) {
const float DIAG = 1.414f;
int size = w * h;
var dist = new float[size];
var crossIds = new byte[size];
// Init: border pixels = 0, interior = large value
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int idx = y * w + x;
byte id = ids[idx];
// Ocean/invalid pixels are always border
if (id == 0 || id == 0xFF) {
dist[idx] = 0f;
crossIds[idx] = id;
continue;
}
// Check 8 neighbors for different province ID
bool isBorder = false;
byte crossId = id;
for (int dy = -1; dy <= 1 && !isBorder; dy++) {
for (int dx = -1; dx <= 1 && !isBorder; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) {
isBorder = true;
crossId = 0;
} else if (ids[ny * w + nx] != id) {
isBorder = true;
crossId = ids[ny * w + nx];
}
}
}
dist[idx] = isBorder ? 0f : 255f;
crossIds[idx] = crossId;
}
}
// Forward pass (top-left to bottom-right)
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int idx = y * w + x;
if (dist[idx] == 0f) continue;
float d = dist[idx];
int bestIdx = idx;
if (x > 0 && dist[idx - 1] + 1f < d) {
d = dist[idx - 1] + 1f;
bestIdx = idx - 1;
}
if (y > 0 && dist[(y - 1) * w + x] + 1f < d) {
d = dist[(y - 1) * w + x] + 1f;
bestIdx = (y - 1) * w + x;
}
if (x > 0 && y > 0 && dist[(y - 1) * w + x - 1] + DIAG < d) {
d = dist[(y - 1) * w + x - 1] + DIAG;
bestIdx = (y - 1) * w + x - 1;
}
if (x < w - 1 && y > 0 && dist[(y - 1) * w + x + 1] + DIAG < d) {
d = dist[(y - 1) * w + x + 1] + DIAG;
bestIdx = (y - 1) * w + x + 1;
}
dist[idx] = d;
if (bestIdx != idx) { crossIds[idx] = crossIds[bestIdx]; }
}
}
// Backward pass (bottom-right to top-left)
for (int y = h - 1; y >= 0; y--) {
for (int x = w - 1; x >= 0; x--) {
int idx = y * w + x;
if (dist[idx] == 0f) continue;
float d = dist[idx];
int bestIdx = idx;
if (x < w - 1 && dist[idx + 1] + 1f < d) {
d = dist[idx + 1] + 1f;
bestIdx = idx + 1;
}
if (y < h - 1 && dist[(y + 1) * w + x] + 1f < d) {
d = dist[(y + 1) * w + x] + 1f;
bestIdx = (y + 1) * w + x;
}
if (x < w - 1 && y < h - 1 && dist[(y + 1) * w + x + 1] + DIAG < d) {
d = dist[(y + 1) * w + x + 1] + DIAG;
bestIdx = (y + 1) * w + x + 1;
}
if (x > 0 && y < h - 1 && dist[(y + 1) * w + x - 1] + DIAG < d) {
d = dist[(y + 1) * w + x - 1] + DIAG;
bestIdx = (y + 1) * w + x - 1;
}
dist[idx] = d;
if (bestIdx != idx) { crossIds[idx] = crossIds[bestIdx]; }
}
}
// Pack into byte arrays
var distResult = new byte[size];
for (int i = 0; i < size; i++) {
distResult[i] = (byte)Mathf.Min(Mathf.RoundToInt(dist[i]), 255);
}
return (distResult, crossIds);
}
void OnDestroy() {
if (_distanceTexture != null) { Destroy(_distanceTexture); }
if (_crossProvinceTexture != null) { Destroy(_crossProvinceTexture); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00eaff8c0b6d46d6bf24c2b841376b0f
@@ -0,0 +1,114 @@
using System.Collections.Generic;
using UnityEngine;
namespace eagle {
/// <summary>
/// Manages 256x1 lookup textures for per-province border highlighting.
/// Border color and width are set per province and uploaded each frame
/// when dirty, mirroring ProvinceColorManager's batched approach.
/// Also manages a highlight-group lookup so the shader can suppress
/// highlight borders between provinces in the same group (e.g. same faction).
/// </summary>
public class ProvinceBorderManager : MonoBehaviour {
[Header("Material")]
public Material provinceMapMaterial;
[Header("Default Borders")]
public float defaultBorderWidth = 0.5f;
public Color defaultBorderColor = new Color(0.15f, 0.1f, 0.05f, 0.8f);
private Texture2D _borderColorLookup;
private Texture2D _borderWidthLookup;
private Texture2D _highlightGroupLookup;
private Color[] _borderColors;
private Color[] _borderWidths;
private Color[] _highlightGroups;
private bool _dirty;
private bool _groupDirty;
void Awake() {
_borderColorLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
_borderColorLookup.filterMode = FilterMode.Point;
_borderColorLookup.wrapMode = TextureWrapMode.Clamp;
_borderWidthLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
_borderWidthLookup.filterMode = FilterMode.Point;
_borderWidthLookup.wrapMode = TextureWrapMode.Clamp;
_highlightGroupLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
_highlightGroupLookup.filterMode = FilterMode.Point;
_highlightGroupLookup.wrapMode = TextureWrapMode.Clamp;
_borderColors = new Color[256];
_borderWidths = new Color[256];
_highlightGroups = new Color[256];
for (int i = 0; i < 256; i++) {
_borderColors[i] = Color.clear;
_borderWidths[i] = Color.clear;
_highlightGroups[i] = Color.clear;
}
_borderColorLookup.SetPixels(_borderColors);
_borderColorLookup.Apply();
_borderWidthLookup.SetPixels(_borderWidths);
_borderWidthLookup.Apply();
_highlightGroupLookup.SetPixels(_highlightGroups);
_highlightGroupLookup.Apply();
provinceMapMaterial.SetTexture("_BorderColorLookup", _borderColorLookup);
provinceMapMaterial.SetTexture("_BorderWidthLookup", _borderWidthLookup);
provinceMapMaterial.SetTexture("_HighlightGroupLookup", _highlightGroupLookup);
provinceMapMaterial.SetFloat("_DefaultBorderWidth", defaultBorderWidth);
provinceMapMaterial.SetColor("_DefaultBorderColor", defaultBorderColor);
}
public void SetProvinceBorder(int provinceId, Color color, float widthScreenPx) {
if (provinceId < 0 || provinceId > 255) return;
_borderColors[provinceId] = color;
_borderWidths[provinceId] = new Color(widthScreenPx / 255f, 0, 0, 0);
_dirty = true;
}
public void ClearProvinceBorder(int provinceId) {
if (provinceId < 0 || provinceId > 255) return;
_borderColors[provinceId] = Color.clear;
_borderWidths[provinceId] = Color.clear;
_dirty = true;
}
public void SetHighlightGroup(IList<int> provinceIds) {
for (int i = 0; i < 256; i++) { _highlightGroups[i] = Color.clear; }
var groupColor = new Color(1f / 255f, 0, 0, 0);
foreach (var id in provinceIds) {
if (id >= 0 && id <= 255) { _highlightGroups[id] = groupColor; }
}
_groupDirty = true;
}
public void ClearHighlightGroup() {
for (int i = 0; i < 256; i++) { _highlightGroups[i] = Color.clear; }
_groupDirty = true;
}
void LateUpdate() {
if (_dirty) {
_borderColorLookup.SetPixels(_borderColors);
_borderColorLookup.Apply();
_borderWidthLookup.SetPixels(_borderWidths);
_borderWidthLookup.Apply();
_dirty = false;
}
if (_groupDirty) {
_highlightGroupLookup.SetPixels(_highlightGroups);
_highlightGroupLookup.Apply();
_groupDirty = false;
}
}
void OnDestroy() {
if (_borderColorLookup != null) Destroy(_borderColorLookup);
if (_borderWidthLookup != null) Destroy(_borderWidthLookup);
if (_highlightGroupLookup != null) Destroy(_highlightGroupLookup);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0bce7ac95ae2407096ea1d4af29f04e0
@@ -12,6 +12,17 @@ Shader "Eagle/ProvinceMap"
_OceanWaveSpeed ("Ocean Wave Speed", Float) = 1.0
_Cutoff ("Alpha cutoff", Range (0,1)) = 0.1
// Province border rendering
_BorderDistanceMap ("Border Distance Map", 2D) = "white" {}
_BorderColorLookup ("Border Color Lookup (256x1)", 2D) = "black" {}
_BorderWidthLookup ("Border Width Lookup (256x1)", 2D) = "black" {}
_DefaultBorderWidth ("Default Border Width", Float) = 0.35
_DefaultBorderColor ("Default Border Color", Color) = (0.15, 0.1, 0.05, 0.8)
// Same-faction border suppression
_CrossProvinceIDMap ("Cross Province ID Map", 2D) = "black" {}
_HighlightGroupLookup ("Highlight Group Lookup (256x1)", 2D) = "black" {}
// UI clipping support
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
@@ -80,13 +91,21 @@ Shader "Eagle/ProvinceMap"
sampler2D _ProvinceIDMap;
sampler2D _ColorLookup;
sampler2D _OceanTex;
sampler2D _BorderDistanceMap;
sampler2D _BorderColorLookup;
sampler2D _BorderWidthLookup;
sampler2D _CrossProvinceIDMap;
sampler2D _HighlightGroupLookup;
float4 _ProvinceIDMap_ST;
float4 _OceanTex_ST;
float4 _BorderDistanceMap_TexelSize; // Unity auto-provides (1/w, 1/h, w, h)
fixed4 _OceanColor;
float4 _OceanScrollSpeed;
float _OceanWaveStrength;
float _OceanWaveSpeed;
float _Cutoff;
float _DefaultBorderWidth;
fixed4 _DefaultBorderColor;
float4 _ClipRect;
v2f vert (appdata v)
@@ -131,9 +150,50 @@ Shader "Eagle/ProvinceMap"
}
else
{
// Look up color from 256x1 texture
// provinceId is already 0-1 normalized, use directly as U coordinate
color = tex2D(_ColorLookup, float2(provinceId, 0.5));
// Look up fill color from 256x1 texture
fixed4 fillColor = tex2D(_ColorLookup, float2(provinceId, 0.5));
// Distance to nearest province boundary (0 = border, 255 = deep interior)
float dist = tex2D(_BorderDistanceMap, i.uv).r * 255.0;
// Resolution-independent scaling: how many texels span one screen pixel
float texelsPerPixel = fwidth(i.uv.x) * _BorderDistanceMap_TexelSize.z;
// Convert screen-pixel widths to texel widths
float effectiveDefaultWidth = _DefaultBorderWidth * texelsPerPixel;
// Per-province highlight border (animated width from C#, in screen pixels)
float highlightWidthPx = tex2D(_BorderWidthLookup, float2(provinceId, 0.5)).r * 255.0;
float effectiveHighlightWidth = highlightWidthPx * texelsPerPixel;
fixed4 highlightColor = tex2D(_BorderColorLookup, float2(provinceId, 0.5));
// Check if the province across the nearest border is in the
// same highlight group (e.g. same faction). When both sides
// belong to the same group the highlight border is suppressed
// so the faction's territory looks like one contiguous region.
float crossProvId = tex2D(_CrossProvinceIDMap, i.uv).r;
float myGroup = tex2D(_HighlightGroupLookup, float2(provinceId, 0.5)).r;
float crossGroup = tex2D(_HighlightGroupLookup, float2(crossProvId, 0.5)).r;
bool sameGroup = myGroup > 0.001 && abs(myGroup - crossGroup) < 0.001;
// Scale anti-aliasing transitions
float defaultTransition = 0.5 * texelsPerPixel;
float highlightTransition = 1.5 * texelsPerPixel;
if (effectiveHighlightWidth > 0.5 && dist <= effectiveHighlightWidth && !sameGroup)
{
float edge = 1.0 - saturate((dist - effectiveHighlightWidth + highlightTransition) / highlightTransition);
color = lerp(fillColor, highlightColor, highlightColor.a * edge);
}
else if (dist <= effectiveDefaultWidth)
{
float edge = 1.0 - saturate((dist - effectiveDefaultWidth + defaultTransition) / defaultTransition);
color = lerp(fillColor, _DefaultBorderColor, _DefaultBorderColor.a * edge);
}
else
{
color = fillColor;
}
}
// Alpha cutoff
@@ -3,8 +3,8 @@
{
"id": 4,
"name": "Berkorszag",
"centroid_x": 1757.8,
"centroid_y": 1231.9,
"centroid_x": 1767.0,
"centroid_y": 1296.0,
"area": 247272,
"orientation": -20.1884829564446,
"principal_length": 873.5,
@@ -14,8 +14,8 @@
{
"id": 27,
"name": "Chapellia",
"centroid_x": 3076.8,
"centroid_y": 508.9,
"centroid_x": 3077.0,
"centroid_y": 509.0,
"area": 37546,
"orientation": -70.52505264723656,
"principal_length": 297.6,
@@ -25,8 +25,8 @@
{
"id": 28,
"name": "Kezonoria",
"centroid_x": 2204.7,
"centroid_y": 1263.1,
"centroid_x": 2215.0,
"centroid_y": 1251.0,
"area": 126885,
"orientation": -35.386786918677814,
"principal_length": 753.6,
@@ -36,7 +36,7 @@
{
"id": 29,
"name": "Mesh",
"centroid_x": 2832.3,
"centroid_x": 2832.0,
"centroid_y": 1017.0,
"area": 197788,
"orientation": -26.173956537897066,
@@ -47,8 +47,8 @@
{
"id": 36,
"name": "Pemeria",
"centroid_x": 2839.4,
"centroid_y": 1290.4,
"centroid_x": 2835.0,
"centroid_y": 1282.0,
"area": 36356,
"orientation": -44.16012147839107,
"principal_length": 457.1,
@@ -58,8 +58,8 @@
{
"id": 37,
"name": "Yuetia",
"centroid_x": 896.1,
"centroid_y": 1227.1,
"centroid_x": 902.0,
"centroid_y": 1132.0,
"area": 362211,
"orientation": 0.6747576339005641,
"principal_length": 1261.0,
@@ -69,8 +69,8 @@
{
"id": 39,
"name": "Kojaria",
"centroid_x": 3233.1,
"centroid_y": 678.6,
"centroid_x": 3234.0,
"centroid_y": 678.0,
"area": 37489,
"orientation": -25.04939634407932,
"principal_length": 301.9,
@@ -80,8 +80,8 @@
{
"id": 43,
"name": "Chia",
"centroid_x": 1134.5,
"centroid_y": 1519.3,
"centroid_x": 1177.0,
"centroid_y": 1499.0,
"area": 75627,
"orientation": -35.91298964206631,
"principal_length": 441.6,
@@ -91,8 +91,8 @@
{
"id": 18,
"name": "Alah",
"centroid_x": 3057.6,
"centroid_y": 1330.3,
"centroid_x": 3056.0,
"centroid_y": 1330.0,
"area": 35076,
"orientation": -25.515287772906877,
"principal_length": 285.6,
@@ -102,8 +102,8 @@
{
"id": 34,
"name": "Turton",
"centroid_x": 2586.7,
"centroid_y": 1506.5,
"centroid_x": 2598.0,
"centroid_y": 1500.0,
"area": 37458,
"orientation": -89.27834794069042,
"principal_length": 295.6,
@@ -113,8 +113,8 @@
{
"id": 41,
"name": "Parnia",
"centroid_x": 397.9,
"centroid_y": 962.3,
"centroid_x": 398.0,
"centroid_y": 963.0,
"area": 184733,
"orientation": 80.31028460303715,
"principal_length": 816.0,
@@ -124,8 +124,8 @@
{
"id": 7,
"name": "Oryslia",
"centroid_x": 2494.9,
"centroid_y": 1104.4,
"centroid_x": 2466.0,
"centroid_y": 1058.0,
"area": 108679,
"orientation": 53.601133897917535,
"principal_length": 673.2,
@@ -135,7 +135,7 @@
{
"id": 6,
"name": "Soria",
"centroid_x": 611.2,
"centroid_x": 611.0,
"centroid_y": 587.0,
"area": 37567,
"orientation": 73.04044050523646,
@@ -146,7 +146,7 @@
{
"id": 26,
"name": "Reigia",
"centroid_x": 1538.6,
"centroid_x": 1538.0,
"centroid_y": 824.0,
"area": 105738,
"orientation": -56.461232070203295,
@@ -157,8 +157,8 @@
{
"id": 21,
"name": "Sigkarl",
"centroid_x": 1776.1,
"centroid_y": 688.7,
"centroid_x": 1776.0,
"centroid_y": 688.0,
"area": 52475,
"orientation": -80.26596706568765,
"principal_length": 411.4,
@@ -168,8 +168,8 @@
{
"id": 42,
"name": "Grytrand",
"centroid_x": 1064.1,
"centroid_y": 369.5,
"centroid_x": 1071.0,
"centroid_y": 373.0,
"area": 37511,
"orientation": 61.866124085696136,
"principal_length": 315.1,
@@ -179,8 +179,8 @@
{
"id": 13,
"name": "Al Raala",
"centroid_x": 3164.8,
"centroid_y": 1142.9,
"centroid_x": 3165.0,
"centroid_y": 1142.0,
"area": 64624,
"orientation": 1.6109698686653782,
"principal_length": 417.0,
@@ -190,8 +190,8 @@
{
"id": 3,
"name": "Laufarvia",
"centroid_x": 770.1,
"centroid_y": 437.8,
"centroid_x": 770.0,
"centroid_y": 437.0,
"area": 37521,
"orientation": -42.64914861683819,
"principal_length": 253.6,
@@ -201,7 +201,7 @@
{
"id": 8,
"name": "Atfordia",
"centroid_x": 2473.6,
"centroid_x": 2474.0,
"centroid_y": 527.0,
"area": 78637,
"orientation": -15.123132625107454,
@@ -212,8 +212,8 @@
{
"id": 33,
"name": "Usvol",
"centroid_x": 939.2,
"centroid_y": 417.9,
"centroid_x": 939.0,
"centroid_y": 418.0,
"area": 37617,
"orientation": 72.87731176765703,
"principal_length": 329.4,
@@ -223,8 +223,8 @@
{
"id": 9,
"name": "Bolve",
"centroid_x": 1554.3,
"centroid_y": 567.8,
"centroid_x": 1554.0,
"centroid_y": 568.0,
"area": 37519,
"orientation": -28.254514479055615,
"principal_length": 297.1,
@@ -234,8 +234,8 @@
{
"id": 17,
"name": "Hofolen",
"centroid_x": 1193.4,
"centroid_y": 496.5,
"centroid_x": 1192.0,
"centroid_y": 497.0,
"area": 37530,
"orientation": -40.73421792639953,
"principal_length": 321.3,
@@ -245,8 +245,8 @@
{
"id": 15,
"name": "Pieksa",
"centroid_x": 3373.6,
"centroid_y": 752.0,
"centroid_x": 3372.0,
"centroid_y": 753.0,
"area": 37474,
"orientation": -42.30480488812397,
"principal_length": 302.7,
@@ -256,8 +256,8 @@
{
"id": 1,
"name": "Shumal",
"centroid_x": 2841.2,
"centroid_y": 1426.4,
"centroid_x": 2841.0,
"centroid_y": 1426.0,
"area": 35197,
"orientation": -26.08271079122629,
"principal_length": 286.9,
@@ -267,8 +267,8 @@
{
"id": 5,
"name": "Musland",
"centroid_x": 2776.1,
"centroid_y": 685.8,
"centroid_x": 2776.0,
"centroid_y": 686.0,
"area": 88353,
"orientation": -67.33176588364896,
"principal_length": 410.3,
@@ -278,8 +278,8 @@
{
"id": 19,
"name": "Hella",
"centroid_x": 809.4,
"centroid_y": 600.1,
"centroid_x": 810.0,
"centroid_y": 600.0,
"area": 37632,
"orientation": -29.964829271657674,
"principal_length": 292.4,
@@ -289,7 +289,7 @@
{
"id": 24,
"name": "Tatoros",
"centroid_x": 2452.8,
"centroid_x": 2453.0,
"centroid_y": 1508.0,
"area": 37547,
"orientation": 64.24404335610743,
@@ -300,8 +300,8 @@
{
"id": 10,
"name": "Vovimaria",
"centroid_x": 608.9,
"centroid_y": 818.4,
"centroid_x": 613.0,
"centroid_y": 807.0,
"area": 37474,
"orientation": 42.325246247463234,
"principal_length": 451.9,
@@ -311,8 +311,8 @@
{
"id": 14,
"name": "Onmaa",
"centroid_x": 3464.3,
"centroid_y": 864.9,
"centroid_x": 3465.0,
"centroid_y": 876.0,
"area": 48302,
"orientation": -33.08899725807026,
"principal_length": 465.4,
@@ -323,7 +323,7 @@
"id": 32,
"name": "Ingia",
"centroid_x": 445.0,
"centroid_y": 614.2,
"centroid_y": 615.0,
"area": 37475,
"orientation": 40.330942997303495,
"principal_length": 270.3,
@@ -333,8 +333,8 @@
{
"id": 11,
"name": "Garholtia",
"centroid_x": 768.3,
"centroid_y": 805.8,
"centroid_x": 768.0,
"centroid_y": 807.0,
"area": 37509,
"orientation": 73.25523522382795,
"principal_length": 275.4,
@@ -344,8 +344,8 @@
{
"id": 2,
"name": "Motcia",
"centroid_x": 2989.1,
"centroid_y": 672.1,
"centroid_x": 2989.0,
"centroid_y": 674.0,
"area": 37541,
"orientation": 71.01019565314077,
"principal_length": 340.1,
@@ -355,8 +355,8 @@
{
"id": 20,
"name": "Elekes",
"centroid_x": 2354.4,
"centroid_y": 1346.4,
"centroid_x": 2355.0,
"centroid_y": 1346.0,
"area": 37570,
"orientation": -4.693928802826447,
"principal_length": 300.8,
@@ -366,8 +366,8 @@
{
"id": 16,
"name": "Oscasland",
"centroid_x": 2033.5,
"centroid_y": 740.9,
"centroid_x": 2033.0,
"centroid_y": 761.0,
"area": 92595,
"orientation": -7.875650568974214,
"principal_length": 519.3,
@@ -377,8 +377,8 @@
{
"id": 30,
"name": "Tegrot",
"centroid_x": 1736.5,
"centroid_y": 1018.5,
"centroid_x": 1736.0,
"centroid_y": 1018.0,
"area": 122426,
"orientation": -20.247381662688625,
"principal_length": 568.2,
@@ -387,9 +387,9 @@
},
{
"id": 40,
"name": "Faluria",
"centroid_x": 976.7,
"centroid_y": 744.3,
"name": "West Faluria",
"centroid_x": 976.0,
"centroid_y": 746.0,
"area": 80344,
"orientation": -62.2520755175581,
"principal_length": 494.3,
@@ -398,9 +398,9 @@
},
{
"id": 25,
"name": "Fluria",
"centroid_x": 1245.7,
"centroid_y": 835.6,
"name": "East Faluria",
"centroid_x": 1245.0,
"centroid_y": 836.0,
"area": 155157,
"orientation": -66.31259041323624,
"principal_length": 561.7,
@@ -410,8 +410,8 @@
{
"id": 35,
"name": "Tumala",
"centroid_x": 3211.2,
"centroid_y": 1409.8,
"centroid_x": 3210.0,
"centroid_y": 1409.0,
"area": 25474,
"orientation": 33.16003147165417,
"principal_length": 283.2,
@@ -421,8 +421,8 @@
{
"id": 31,
"name": "Nikemi",
"centroid_x": 3200.5,
"centroid_y": 882.0,
"centroid_x": 3200.0,
"centroid_y": 881.0,
"area": 40737,
"orientation": 57.09128390939083,
"principal_length": 378.6,
@@ -432,8 +432,8 @@
{
"id": 22,
"name": "Chipingia",
"centroid_x": 2206.4,
"centroid_y": 580.2,
"centroid_x": 2209.0,
"centroid_y": 588.0,
"area": 40656,
"orientation": -48.11179717863961,
"principal_length": 286.5,
@@ -443,8 +443,8 @@
{
"id": 23,
"name": "Eivikia",
"centroid_x": 1359.5,
"centroid_y": 506.6,
"centroid_x": 1360.0,
"centroid_y": 506.0,
"area": 37555,
"orientation": -49.357592556477876,
"principal_length": 285.3,
@@ -454,8 +454,8 @@
{
"id": 38,
"name": "Wichel",
"centroid_x": 2399.6,
"centroid_y": 792.4,
"centroid_x": 2395.0,
"centroid_y": 781.0,
"area": 122281,
"orientation": -26.154786547398853,
"principal_length": 627.0,
@@ -465,8 +465,8 @@
{
"id": 12,
"name": "Pozia",
"centroid_x": 2950.6,
"centroid_y": 338.6,
"centroid_x": 2960.0,
"centroid_y": 410.0,
"area": 56289,
"orientation": -65.77001402153415,
"principal_length": 522.5,
@@ -474,4 +474,4 @@
"curvature": -9.3
}
]
}
}
@@ -1,130 +0,0 @@
fileFormatVersion: 2
guid: 14b4504cd5b0e466d985c9f7ac241f30
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -96,6 +96,14 @@ public class EagleConnection : IDisposable {
return new EagleConnection(url, useJwt: true);
}
/// <summary>
/// Create an insecure connection with no auth for local QA testing.
/// Connects over plain HTTP with no interceptor. Use with a server running --skip-auth.
/// </summary>
public static EagleConnection CreateInsecure(string fullUrl) {
return new EagleConnection(fullUrl, insecure: true, jwt: false);
}
private EagleConnection(string url, bool useJwt) {
// For JWT auth, get display name from TokenStorage
playerName = TokenStorage.DisplayName ?? "Unknown";
@@ -108,6 +116,26 @@ public class EagleConnection : IDisposable {
EagleGrpcClient = new Eagle.EagleClient(MakeJwtInvoker(url));
}
private EagleConnection(string fullUrl, bool insecure, bool jwt) {
playerName = "editor";
credentials = new Metadata { { "user", "editor" } };
authHeader = null;
_loggerFactory = LoggerFactory.Create(
builder => { builder.AddProvider(new CustomFileLoggerProvider()); });
_channel = GrpcChannel.ForAddress(
fullUrl,
new GrpcChannelOptions {
HttpHandler = CreateHttpHandler(),
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
});
EagleGrpcClient = new Eagle.EagleClient(_channel);
}
public void Dispose() {
try {
_channel?.Dispose();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2cd70b162309940b1bf97c3077e07322
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,186 @@
{
"scripts": [
{
"id": "tutorial_placement",
"trigger": "shardok_placement_started",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "There they are. Tarn has brought his Shardok's Guard \u2014 the finest soldiers in the realm \u2014 and knights besides. Do not underestimate them, Sadar. Every one of those men has seen more battle than we care to imagine.",
"instructionText": "Hostile forces appear with <color=#CC0000><b>red</b></color> text on the battlefield.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Let me take stock. We have <b>light infantry</b> and <b>longbowmen</b> \u2014 not much, but the longbows can strike from range and they're deadly against Tarn's armored <b>knights</b> and <b>heavy infantry</b>. And he's brought <b>dragoons</b> \u2014 fast cavalry, hard to pin down. We'll need every advantage we can get.",
"instructionText": "<sprite name=\"LightInfantry\"> <b>Light Infantry</b> \u2014 Fast, lightly armored\n<sprite name=\"Longbowmen\"> <b>Longbowmen</b> \u2014 Ranged attack, strong vs. armor\n<sprite name=\"HeavyInfantry\"> <b>Heavy Infantry</b> \u2014 Slow, heavily armored (enemy)\n<sprite name=\"HeavyCavalry\"> <b>Knights</b> \u2014 Mounted, powerful charge (enemy)\n<sprite name=\"LightCavalry\"> <b>Dragoons</b> \u2014 Fast cavalry, charge and reposition (enemy)",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "We must hold the castles. If we can dig in and weather the assault, we may yet survive this. Position our forces carefully \u2014 every man counts today.",
"instructionText": "Friendly units with actions available have <color=#0000FF><b>blue</b></color> text. Units without actions have <b>black</b> text.\n\nClick a unit to select it, then click a flashing eligible position to place it. Click <b>Commit</b> when finished.",
"highlightTarget": "CommitButton",
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_battle_running",
"trigger": "shardok_battle_running",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Now the real fight begins. Remember, Sadar \u2014 Tarn wins if he wipes us out or seizes every castle on the field. We win if we destroy his forces, or simply hold out for a full month. Time is on our side, if we can endure.",
"instructionText": "The <b>attacker</b> wins by eliminating the defender's forces or occupying all castles.\n\nThe <b>defender</b> wins by eliminating the attacker's forces or surviving a full month.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "For now, our best move is to hold position and let them come to us. The castles give us the advantage \u2014 we'd be fools to abandon that.",
"instructionText": "You can move a unit by clicking it, then clicking an eligible destination. But for now, it's best to click <b>End Turn</b> and let the enemy approach.",
"highlightTarget": "EndTurnButton",
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_archery",
"trigger": "archery_available",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "The enemy draws near! Unleash everything we have, Sadar \u2014 every arrow, every bolt! Let them know the Reclamation will not go quietly!",
"instructionText": "Click a unit to select it. Eligible archery targets will appear with <color=#CC00FF><b>purple outlines</b></color> \u2014 click one to fire.\n\n<b>Longbow</b> units (marked with a bow symbol) are especially effective against heavily armored targets like knights.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_charge",
"trigger": "ability_charge_available",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Sadar, look \u2014 one of our units has a clear line to charge! A charge delivers extra damage and can stun the enemy, especially with cavalry. We must seize the opportunity!",
"instructionText": "When a unit can move to a position that allows a follow-up charge, the destination shows a <sprite name=\"Charge\"> icon. Click the unit, then click the marked destination to move and charge in one action.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_melee",
"trigger": "melee_available",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "They're right on top of us! Steel to steel, then \u2014 give them a fight they won't forget!",
"instructionText": "To melee attack, select a unit and <b>Shift-click</b> an adjacent enemy. You can also click the <b>Melee</b> button on the right, then click the target.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_thunderstorm",
"trigger": "thunderstorm",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "A thunderstorm! The gods have a sense of timing, at least. Our bowmen won't be able to fire in this \u2014 but neither will theirs. And if anything's burning, the rain will put it out.",
"instructionText": "<b>Thunderstorms</b> prevent all archery attacks and extinguish fires on the battlefield.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_start_fire",
"trigger": "start_fire_available",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "We're close enough to set their position ablaze! Fire spreads quickly and can force the enemy to abandon favorable ground \u2014 or burn trying to hold it.",
"instructionText": "Select a unit adjacent to the enemy, then click the <b>Start Fire</b> button on the right to set fire to a hex. Fire damages units that enter or remain in it, and spreads over time.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
},
{
"id": "tutorial_reinforcements",
"trigger": "tutorial_reinforcements_arrived",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Ah \u2014 Ranil, Elena, Hedrick. I wasn't sure you'd come. Desperate times, it seems, make for unlikely alliances.",
"instructionText": null,
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "John Ranil",
"speakerImagePath": null,
"dialogueText": "I sat out as long as I could, Sadar. Tried to keep my people safe and stay out of it. But Tarn's forces pushed through my province last month and didn't much care who was in the way. So here I am \u2014 and I brought my tools. Point me at something that needs building or breaking.",
"instructionText": "<sprite name=\"Engineer\"> <b>Engineer</b> \u2014 Can repair or build bridges and castles, and fortify a position. While fortified, can bombard enemy-held castles from range.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Elena Fyar",
"speakerImagePath": null,
"dialogueText": "I warned my father about Tarn years ago, and no one listened. Well \u2014 here we are. I've brought what's left of the Order with me. If your men are wounded, I can help with that. And if Tarn has brought anything... unnatural... I can help with that too.",
"instructionText": "<sprite name=\"Paladin\"> <b>Paladin</b> \u2014 Can unleash a Holy Wave that restores vigor to adjacent friendly units and destroys any undead among them.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Hedrick the Hedge-merchant",
"speakerImagePath": null,
"dialogueText": "I was trying to sell shrubberies in Ingia when your man found me, Marek. Business has been terrible \u2014 turns out nobody buys decorative hedges during a civil war. But I know every back trail and hollow between here and the coast, and I know how to make myself scarce when I need to.",
"instructionText": "<sprite name=\"Ranger\"> <b>Ranger</b> \u2014 Can hide in forests and swamps, ambush enemies emerging from concealment, and scout to reveal hidden enemy positions.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 026a02f9c79a24ed69130cdb081343d7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,35 @@
{
"scripts": [
{
"id": "tutorial_opening",
"trigger": "game_started",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant \u2014 the right hand of the Shardok himself. But over the past year, Tarn's behavior grew ever more erratic. The paranoia, the inscrutable orders, those strange training exercises no one could explain... and King Bregos would hear none of it.\n\nSo you did what had to be done. You and a handful of others raised the banner of the Reclamation \u2014 not against the Crown, but against the man who wields its sword and can no longer be trusted with it.",
"instructionText": null,
"highlightTarget": null,
"completionEvent": null
},
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "But Tarn still commands the royal army, and he is perhaps the most capable soldier this realm has ever seen. Your allies are scattered. You've been pushed back to Onmaa, your back to the sea. The rebellion teeters on the edge of defeat.\n\nThis is the last stand, Sadar. If we fall here, the Reclamation dies with us.",
"instructionText": null,
"highlightTarget": null,
"highlightProvince": "Onmaa",
"completionEvent": null
},
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Tarn's forces are already at the gates. If we survive this day... well. Perhaps there is still a way to reach the King's ear. But first, we must survive.\n\nJoin the battle. Let us make our stand.",
"instructionText": "Click <b>Battle!</b> to enter tactical combat.",
"highlightTarget": "GoToBattleButton",
"completionEvent": null
}
]
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b3aeca1f3713f4c08848a7bc55846f5a
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -66,6 +66,9 @@ public class HexGrid : MonoBehaviour {
public Texture[] textures;
[Header("3D Object Settings")]
public float bridgeScale = 22f;
[Header("Label Settings")]
public Color labelOutlineColor = Color.white;
public float labelOutlineWidth = 0.25f;
@@ -794,7 +797,6 @@ public class HexGrid : MonoBehaviour {
Vector2 cellCenter = cell.Geometry.AnchoredPosition;
GameObject newObject = Instantiate(original: prefab, parent: gridTransform);
newObject.transform.localPosition = new Vector3(x: cellCenter.x, y: cellCenter.y, z: -5f);
float bridgeScale = 15f;
newObject.transform.localScale =
new Vector3(x: bridgeScale, y: bridgeScale, z: bridgeScale);
// rotationDegrees is already the X rotation (0, 56, or 124)
@@ -452,7 +452,11 @@ namespace Shardok {
Model.Month);
}
}
MainQueue.Q.Enqueue(() => {
// Defer grid setup by one frame so Unity's layout system fully settles
// after the Shardok container activation. Without this delay, mapArea may
// report a stale size when SetUp() measures it, causing a visible grid
// rebuild on the following frame.
MainQueue.Q.EnqueueForNextUpdate(() => {
hexGrid.height = map.RowCount;
hexGrid.width = map.ColumnCount;
hexGrid.textures = textures;
@@ -472,6 +476,11 @@ namespace Shardok {
// Initialize tutorial system for tactical combat
TutorialManager.Instance?.Initialize(null, this);
var endTurnRT = endTurnButton.GetComponent<RectTransform>();
TutorialManager.Instance?.TargetRegistry?.RegisterTarget("CommitButton", endTurnRT);
TutorialManager.Instance?.TargetRegistry?.RegisterTarget(
"EndTurnButton",
endTurnRT);
TutorialManager.Instance?.TriggerRegistry?.OnBattleEntered(Model);
#if UNITY_EDITOR
@@ -1307,7 +1316,9 @@ namespace Shardok {
command.Type ==
CommandType.PlaceHiddenUnitCommand)
.Where(command => command.Actor == unplacedUnit.UnitId);
} else if (reservesController.SelectedUnit != null) {
} else if (
reservesController.SelectedUnit != null && SelectedCoords != null &&
Model.UnitAtCoords(SelectedCoords) != null) {
UnitViewWithName unplacedUnit = reservesController.SelectedUnit;
candidateCommands =
@@ -2231,15 +2242,15 @@ namespace Shardok {
}
}
// Normal behavior: select the reserve unit for placement
_selectedGridIndex = null;
// Normal behavior: select the reserve unit for placement/reinforce
if (Model.InSetUp) { _selectedGridIndex = null; }
hexGrid.ClearOverlays();
var cmdGroup = commandTypeUIManager.CommandGroupForType(
Model.InSetUp ? CommandType.PlaceUnitCommand
: CommandType.ReinforceCommand);
SetDisplayedCommandGroup(cmdGroup.Value);
RedrawCommandOverlays(null, null);
RedrawCommandOverlays(_selectedGridIndex, null);
} else {
SetDisplayedCommandGroup(0);
hexGrid.ClearOverlays();
@@ -33,6 +33,7 @@ public class ShardokGameModel {
private readonly Dictionary<HeroId, string> _heroNameTextIds;
private readonly List<HeroNameListener> _heroNameListeners = new();
private readonly Func<HeroId, (string nameTextId, string imagePath)?> _heroFallbackLookup;
public struct PlayerWithHostility {
public PlayerId playerId;
@@ -56,6 +57,34 @@ public class ShardokGameModel {
return textId;
}
/// <summary>
/// Looks up hero name text ID and headshot path, falling back to the eagle model
/// if the hero wasn't known at battle creation (e.g. mid-battle reinforcements).
/// Caches the result locally and registers a ClientTextProvider listener.
/// </summary>
private (string heroNameTextId, string headshotPath) LookupHero(HeroId hid) {
var nameTextId = GetHeroNameTextId(hid);
HeroImages.TryGetValue(hid, out var imagePath);
if (nameTextId == null && imagePath == null && _heroFallbackLookup != null) {
var fallback = _heroFallbackLookup(hid);
if (fallback.HasValue) {
nameTextId = fallback.Value.nameTextId;
imagePath = fallback.Value.imagePath;
if (nameTextId != null) {
_heroNameTextIds[hid] = nameTextId;
var listener = new HeroNameListener(nameTextId, this);
_heroNameListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
if (imagePath != null) { HeroImages[hid] = imagePath; }
}
}
return (nameTextId, imagePath);
}
public void CleanupListeners() {
foreach (var listener in _heroNameListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
@@ -149,7 +178,8 @@ public class ShardokGameModel {
List<PlayerWithHostility> players,
Dictionary<HeroId, String> heroNameTextIds,
Dictionary<HeroId, String> heroImages,
Dictionary<BattalionId, String> battalionNames) {
Dictionary<BattalionId, String> battalionNames,
Func<HeroId, (string nameTextId, string imagePath)?> heroFallbackLookup = null) {
EagleGameId = eagleGameId;
ShardokGameId = shardokGameId;
History = new List<ActionResultView>(StartingHistoryCapacity);
@@ -166,6 +196,7 @@ public class ShardokGameModel {
_heroNameTextIds = heroNameTextIds;
HeroImages = heroImages;
BattalionNames = battalionNames;
_heroFallbackLookup = heroFallbackLookup;
UnitPlayers = new Dictionary<UnitId, PlayerId>();
PlayerTotals = players.Select(p => new PlayerTotals { PlayerId = p.playerId }).ToList();
@@ -422,8 +453,7 @@ public class ShardokGameModel {
string heroNameTextId = null;
string headshotPath = null;
if (changedUnit.AttachedHero?.EagleHeroId is HeroId hid) {
heroNameTextId = GetHeroNameTextId(hid);
HeroImages.TryGetValue(hid, out headshotPath);
(heroNameTextId, headshotPath) = LookupHero(hid);
}
string battalionName = null;
@@ -457,8 +487,7 @@ public class ShardokGameModel {
string heroNameTextId = null;
string headshotPath = null;
if (changedReserveUnit.AttachedHero?.EagleHeroId is HeroId hid) {
heroNameTextId = GetHeroNameTextId(hid);
HeroImages.TryGetValue(hid, out headshotPath);
(heroNameTextId, headshotPath) = LookupHero(hid);
}
string battalionName = null;
@@ -3,10 +3,10 @@ guid: 43bb1d63969274224b3b8ed8af7b5822
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 11
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
@@ -20,9 +20,12 @@ TextureImporter:
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -31,16 +34,16 @@ TextureImporter:
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -49,17 +52,22 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 0
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 1
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -69,24 +77,54 @@ TextureImporter:
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -12,6 +12,10 @@ namespace Eagle0.Tutorial {
/// Creates and registers all tutorial sequences with the registry.
/// </summary>
public static void RegisterAll(TutorialManager manager, TutorialTriggerRegistry registry) {
// Old tutorial content suppressed — replaced by narrative dialogue system.
// Content definitions preserved below for reference/reuse.
return;
// Create and assign onboarding sequence
var onboarding = CreateOnboardingSequence();
manager.OnboardingSequence = onboarding;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f40360ec96e8643b0a29dc6f12f4aff9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Linq;
using eagle;
using eagle0;
using Shardok;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Loads and plays narrative dialogue scripts from JSON.
/// Manages the dialogue panel, speaker headshots, element highlighting,
/// and step advancement.
/// </summary>
public class DialogueManager : MonoBehaviour {
[Header("UI")]
public DialoguePanelController panelController;
[Header("Highlight")]
public Color highlightColor = new Color(1f, 0.85f, 0f);
public float highlightPadding = 5f;
public float highlightThickness = 3f;
public float strobeSpeed = 2f;
public float strobeMinAlpha = 0.2f;
private readonly Dictionary<string, List<DialogueScript>> _triggerMap =
new Dictionary<string, List<DialogueScript>>();
private DialogueScript _activeScript;
private int _currentStepIndex;
private MapController _mapController;
private ShardokGameController _shardokController;
private GameObject _activeHighlight;
private readonly List<Image> _highlightEdges = new List<Image>();
private readonly HashSet<string> _completedScriptIds = new HashSet<string>();
private readonly Queue<string> _pendingTriggers = new Queue<string>();
/// <summary>
/// Whether a dialogue is currently being displayed.
/// </summary>
public bool IsDialogueActive => _activeScript != null;
private void Awake() { LoadAllScripts(); }
private void Update() {
if (_highlightEdges.Count == 0) return;
float alpha =
Mathf.Lerp(strobeMinAlpha, 1f, Mathf.PingPong(Time.time * strobeSpeed, 1f));
var c = new Color(highlightColor.r, highlightColor.g, highlightColor.b, alpha);
foreach (var img in _highlightEdges) {
if (img != null) img.color = c;
}
}
/// <summary>
/// Set the map controller reference for province highlighting.
/// Called from TutorialManager.Initialize().
/// </summary>
public void SetMapController(MapController mapController) {
_mapController = mapController;
}
/// <summary>
/// Set the Shardok controller reference for resolving hero headshots.
/// Called from TutorialManager.Initialize().
/// </summary>
public void SetShardokController(ShardokGameController shardokController) {
_shardokController = shardokController;
}
private void LoadAllScripts() {
var jsonFiles = Resources.LoadAll<TextAsset>("Dialogues");
foreach (var file in jsonFiles) {
var collection = JsonUtility.FromJson<DialogueScriptCollection>(file.text);
if (collection?.scripts == null) continue;
foreach (var script in collection.scripts) {
if (string.IsNullOrEmpty(script.trigger)) continue;
if (!_triggerMap.TryGetValue(script.trigger, out var list)) {
list = new List<DialogueScript>();
_triggerMap[script.trigger] = list;
}
list.Add(script);
}
}
}
/// <summary>
/// Fires a trigger. If any loaded dialogue script matches, plays the first one.
/// Only fires in tutorial games. If a dialogue is already active, queues the trigger.
/// </summary>
public void TriggerDialogue(string trigger) {
if (!TutorialManager.IsTutorialGame) return;
if (!_triggerMap.ContainsKey(trigger)) return;
if (_activeScript != null) {
_pendingTriggers.Enqueue(trigger);
return;
}
PlayTrigger(trigger);
}
private void PlayTrigger(string trigger) {
if (!_triggerMap.TryGetValue(trigger, out var scripts)) return;
foreach (var script in scripts) {
if (!_completedScriptIds.Contains(script.id)) {
StartScript(script);
return;
}
}
}
private void StartScript(DialogueScript script) {
_activeScript = script;
_currentStepIndex = 0;
panelController.SetPanelPosition(script.panelPosition);
ShowCurrentStep();
}
private void ShowCurrentStep() {
if (_activeScript == null) return;
if (_currentStepIndex >= _activeScript.steps.Count) {
EndDialogue();
return;
}
var step = _activeScript.steps[_currentStepIndex];
// Resolve speaker info from game model when headshot not specified in JSON
if (string.IsNullOrEmpty(step.speakerImagePath) &&
!string.IsNullOrEmpty(step.speakerName)) {
var resolved = ResolveSpeaker(step.speakerName);
if (resolved.HasValue) {
step.speakerImagePath = resolved.Value.headshotPath;
step.speakerName = resolved.Value.displayName;
}
}
panelController.Show(step);
// Wire continue button
panelController.continueButton.onClick.RemoveAllListeners();
panelController.continueButton.onClick.AddListener(AdvanceStep);
// Wire skip button
panelController.skipButton.onClick.RemoveAllListeners();
panelController.skipButton.onClick.AddListener(SkipDialogue);
// Handle highlighting
ClearHighlight();
if (!string.IsNullOrEmpty(step.highlightTarget)) {
// Highlight a UI element
var target = TutorialManager.Instance?.TargetRegistry?.GetTargetOrFind(
step.highlightTarget);
if (target != null && target.gameObject.activeInHierarchy) {
HighlightRect(target.GetComponent<RectTransform>());
}
} else if (!string.IsNullOrEmpty(step.highlightProvince) && _mapController != null) {
// Highlight a province on the map
var proxy = _mapController.GetProvinceHighlightProxy(step.highlightProvince);
if (proxy != null) { HighlightRect(proxy); }
}
}
/// <summary>
/// Looks up a hero's display name and headshot path by name (case-insensitive)
/// from the active Shardok game model. Returns null if no match is found.
/// </summary>
private (string displayName, string headshotPath)? ResolveSpeaker(string speakerName) {
var model = _shardokController?.Model;
if (model == null) return null;
var unit = model.UnitsById.Values.Concat(model.ReserveUnitsById.Values)
.FirstOrDefault(
u => string.Equals(
u.Name,
speakerName,
StringComparison.OrdinalIgnoreCase));
if (unit == null) return null;
return (unit.Name, unit.HeadshotPath);
}
/// <summary>
/// Advances to the next dialogue step. Called by the continue button.
/// </summary>
public void AdvanceStep() {
_currentStepIndex++;
if (_currentStepIndex >= _activeScript.steps.Count) {
EndDialogue();
} else {
ShowCurrentStep();
}
}
/// <summary>
/// Skips the entire remaining dialogue. Called by the skip button.
/// </summary>
public void SkipDialogue() { EndDialogue(); }
/// <summary>
/// Ends the current dialogue and cleans up all state.
/// Safe to call even if no dialogue is active.
/// </summary>
public void EndDialogue() {
if (_activeScript != null && !string.IsNullOrEmpty(_activeScript.id)) {
_completedScriptIds.Add(_activeScript.id);
}
_activeScript = null;
_currentStepIndex = 0;
panelController.SetPanelPosition(null);
panelController.Hide();
panelController.continueButton.onClick.RemoveAllListeners();
panelController.skipButton.onClick.RemoveAllListeners();
ClearHighlight();
_mapController?.ClearProvinceHighlightProxy();
// Play next queued dialogue if any
while (_pendingTriggers.Count > 0) {
var next = _pendingTriggers.Dequeue();
PlayTrigger(next);
if (_activeScript != null) break;
}
}
// ========== Highlight as child of target ==========
/// <summary>
/// Creates a border highlight as a direct child of the target RectTransform.
/// This avoids cross-canvas coordinate conversion issues — the border inherits
/// the target's position automatically.
/// </summary>
private void HighlightRect(RectTransform target) {
if (target == null) return;
ClearHighlight();
var go = new GameObject("DialogueHighlight");
go.transform.SetParent(target, false);
var rt = go.AddComponent<RectTransform>();
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(-highlightPadding, -highlightPadding);
rt.offsetMax = new Vector2(highlightPadding, highlightPadding);
CreateBorderEdge(
rt,
"Top",
new Vector2(0, 1),
Vector2.one,
new Vector2(0, -highlightThickness),
Vector2.zero);
CreateBorderEdge(
rt,
"Bottom",
Vector2.zero,
new Vector2(1, 0),
Vector2.zero,
new Vector2(0, highlightThickness));
CreateBorderEdge(
rt,
"Left",
Vector2.zero,
new Vector2(0, 1),
Vector2.zero,
new Vector2(highlightThickness, 0));
CreateBorderEdge(
rt,
"Right",
new Vector2(1, 0),
Vector2.one,
new Vector2(-highlightThickness, 0),
Vector2.zero);
_activeHighlight = go;
}
private void CreateBorderEdge(
RectTransform parent,
string name,
Vector2 anchorMin,
Vector2 anchorMax,
Vector2 offsetMin,
Vector2 offsetMax) {
var edge = new GameObject(name);
edge.transform.SetParent(parent, false);
var rt = edge.AddComponent<RectTransform>();
rt.anchorMin = anchorMin;
rt.anchorMax = anchorMax;
rt.offsetMin = offsetMin;
rt.offsetMax = offsetMax;
var img = edge.AddComponent<Image>();
img.color = highlightColor;
img.raycastTarget = false;
_highlightEdges.Add(img);
}
private void ClearHighlight() {
if (_activeHighlight != null) {
Destroy(_activeHighlight);
_activeHighlight = null;
}
_highlightEdges.Clear();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 87c43c82915f445b3993ea8dc6c39186
@@ -0,0 +1,94 @@
using common;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// UI controller for the narrative dialogue panel.
/// Wired to a Unity panel via Inspector fields.
/// DialogueManager calls Show/Hide to drive the display.
/// </summary>
public class DialoguePanelController : MonoBehaviour {
[Header("Speaker")]
public RawImage speakerHeadshot;
public TMP_Text speakerNameText;
[Header("Content")]
public TMP_Text dialogueText;
public TMP_Text instructionText;
public GameObject instructionContainer;
public TMP_SpriteAsset instructionSpriteAsset;
[Header("Buttons")]
public Button continueButton;
public Button skipButton;
private Vector2 _defaultAnchorMin;
private Vector2 _defaultAnchorMax;
private Vector2 _defaultPivot;
private bool _defaultsSaved;
/// <summary>
/// Repositions the panel vertically. Call before Show().
/// "top" anchors to the top of the screen; null/empty restores default.
/// </summary>
public void SetPanelPosition(string position) {
var rt = GetComponent<RectTransform>();
if (!_defaultsSaved) {
_defaultAnchorMin = rt.anchorMin;
_defaultAnchorMax = rt.anchorMax;
_defaultPivot = rt.pivot;
_defaultsSaved = true;
}
if (position == "top") {
rt.anchorMin = new Vector2(rt.anchorMin.x, 1f);
rt.anchorMax = new Vector2(rt.anchorMax.x, 1f);
rt.pivot = new Vector2(rt.pivot.x, 1f);
rt.anchoredPosition = new Vector2(rt.anchoredPosition.x, -10f);
} else {
rt.anchorMin = _defaultAnchorMin;
rt.anchorMax = _defaultAnchorMax;
rt.pivot = _defaultPivot;
rt.anchoredPosition = new Vector2(rt.anchoredPosition.x, 0f);
}
}
/// <summary>
/// Shows the panel with the given dialogue step content.
/// </summary>
public void Show(DialogueStep step) {
speakerNameText.text = step.speakerName;
dialogueText.text = step.dialogueText;
// Load speaker headshot via ResourceFetcher
if (!string.IsNullOrEmpty(step.speakerImagePath)) {
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
speakerHeadshot,
step.speakerImagePath);
} else {
speakerHeadshot.texture = null;
}
// Show/hide instruction section
bool hasInstruction = !string.IsNullOrEmpty(step.instructionText);
instructionContainer.SetActive(hasInstruction);
if (hasInstruction) {
instructionText.spriteAsset = instructionSpriteAsset;
instructionText.text = step.instructionText;
}
gameObject.SetActive(true);
// Force layout rebuild so ContentSizeFitter updates panel height
LayoutRebuilder.ForceRebuildLayoutImmediate(GetComponent<RectTransform>());
}
/// <summary>
/// Hides the dialogue panel.
/// </summary>
public void Hide() { gameObject.SetActive(false); }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 918aadf8ef43c4e98baed073d6e6d26a
@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
namespace Eagle0.Tutorial {
[Serializable]
public class DialogueScript {
public string id;
public string trigger;
public string panelPosition; // "top" or null for default
public List<DialogueStep> steps;
}
[Serializable]
public class DialogueScriptCollection {
public List<DialogueScript> scripts;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4847dd4e788d54c94aa740ffeddbde24
@@ -0,0 +1,14 @@
using System;
namespace Eagle0.Tutorial {
[Serializable]
public class DialogueStep {
public string speakerName;
public string speakerImagePath;
public string dialogueText;
public string instructionText;
public string highlightTarget;
public string highlightProvince;
public string completionEvent;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 55f1c6b28a2ba45389c5c8196e3f54c2
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8d830fd74ce914e828514e73ecb6a44d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-6234567890123456789
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: champion Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 6ef78325c5250bc40bb7fff9e01a7dcc, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: champion
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -6234567890123456789}
spriteSheet: {fileID: 2800000, guid: 6ef78325c5250bc40bb7fff9e01a7dcc, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Champion
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 6ef78325c5250bc40bb7fff9e01a7dcc, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 73067d8ba7ef428e833a6d031d83fe66
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-7331928456102843519
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: charge Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 43bb1d63969274224b3b8ed8af7b5822, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: charge
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -7331928456102843519}
spriteSheet: {fileID: 2800000, guid: 43bb1d63969274224b3b8ed8af7b5822, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Charge
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 128
m_Height: 74
m_HorizontalBearingX: -64
m_HorizontalBearingY: 66
m_HorizontalAdvance: 128
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 128
m_Height: 74
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 43bb1d63969274224b3b8ed8af7b5822, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f02ce891f1b54a469e6ea565badaa1f5
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,118 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-6970199219496148764
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Blacksmith_30_silver_horseshoe Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: eea23a3fb9d954f469eaf4f888930bd4, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: dragoons
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -6970199219496148764}
spriteSheet: {fileID: 2800000, guid: eea23a3fb9d954f469eaf4f888930bd4, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: LightCavalry
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: eea23a3fb9d954f469eaf4f888930bd4, type: 3}
spriteInfoList: []
fallbackSpriteAssets:
- {fileID: 11400000, guid: 4310e5d1bcccc4f5186ea1d41a6f373f, type: 2}
- {fileID: 11400000, guid: 5ef94b343124b42cfadc62d8ed46e278, type: 2}
- {fileID: 11400000, guid: 9fad0827179504ff1aca13e029f62588, type: 2}
- {fileID: 11400000, guid: 309f2f27c278046cbac995e61a2e7956, type: 2}
- {fileID: 11400000, guid: c2834fa26a6824606a565da687bf8ff3, type: 2}
- {fileID: 11400000, guid: f02ce891f1b54a469e6ea565badaa1f5, type: 2}
- {fileID: 11400000, guid: c0f460df446143f9997cada4c7b61a02, type: 2}
- {fileID: 11400000, guid: c185f2d3dd7546068a38f29fa716ee95, type: 2}
- {fileID: 11400000, guid: af12d2dee1704edbbf19720ab37b8430, type: 2}
- {fileID: 11400000, guid: 8b9e2010102a4d3297964885e3b286bb, type: 2}
- {fileID: 11400000, guid: 6a6ffb7e8e104563b0e2627285158f74, type: 2}
- {fileID: 11400000, guid: 73067d8ba7ef428e833a6d031d83fe66, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5170b1976e3ba433986c5c661454f2fb
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-3234567890123456789
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: engineer Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 3d3cbcae4901f3d4d90d57314e7370f7, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: engineer
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -3234567890123456789}
spriteSheet: {fileID: 2800000, guid: 3d3cbcae4901f3d4d90d57314e7370f7, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Engineer
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 3d3cbcae4901f3d4d90d57314e7370f7, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: af12d2dee1704edbbf19720ab37b8430
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-5303636401254955137
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: mace04 Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 9e15847c53afb14448257c32e4cabe00, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: heavy infantry
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -5303636401254955137}
spriteSheet: {fileID: 2800000, guid: 9e15847c53afb14448257c32e4cabe00, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: HeavyInfantry
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 9e15847c53afb14448257c32e4cabe00, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4310e5d1bcccc4f5186ea1d41a6f373f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-6727445534539906575
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: 2_Mail_head Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: d9177ffe2c0b2b64382bd4e5f3b83fc1, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: knights
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -6727445534539906575}
spriteSheet: {fileID: 2800000, guid: d9177ffe2c0b2b64382bd4e5f3b83fc1, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: HeavyCavalry
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: d9177ffe2c0b2b64382bd4e5f3b83fc1, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ef94b343124b42cfadc62d8ed46e278
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-166228656761865305
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: sword02 Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 2c1fa953390c43f4e9385328f77ee967, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: light infantry
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -166228656761865305}
spriteSheet: {fileID: 2800000, guid: 2c1fa953390c43f4e9385328f77ee967, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: LightInfantry
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 2c1fa953390c43f4e9385328f77ee967, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9fad0827179504ff1aca13e029f62588
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-6519500375026363721
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Bow_39 Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: c6aa4e8cbfbcff240a893a7afe42c83d, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: longbowmen
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -6519500375026363721}
spriteSheet: {fileID: 2800000, guid: c6aa4e8cbfbcff240a893a7afe42c83d, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Longbowmen
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: c6aa4e8cbfbcff240a893a7afe42c83d, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 309f2f27c278046cbac995e61a2e7956
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-1234567890123456789
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: mage Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 3dc4132ad69f42b458976f624ccd771d, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: mage
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -1234567890123456789}
spriteSheet: {fileID: 2800000, guid: 3dc4132ad69f42b458976f624ccd771d, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Mage
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 3dc4132ad69f42b458976f624ccd771d, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c0f460df446143f9997cada4c7b61a02
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-2234567890123456789
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: necromancer Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 1fda22c5bfb926f4cabc0303e9dbc08b, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: necromancer
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -2234567890123456789}
spriteSheet: {fileID: 2800000, guid: 1fda22c5bfb926f4cabc0303e9dbc08b, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Necromancer
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: 1fda22c5bfb926f4cabc0303e9dbc08b, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c185f2d3dd7546068a38f29fa716ee95
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &-4234567890123456789
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: paladin Material
m_Shader: {fileID: 4800000, guid: cf81c85f95fe47e1a27f6ae460cf182c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: ce3bc31ab20668048b10a34c9d8f7c3b, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ColorMask: 15
- _CullMode: 0
- _Stencil: 0
- _StencilComp: 8
- _StencilOp: 0
- _StencilReadMask: 255
- _StencilWriteMask: 255
- _UseUIAlphaClip: 0
m_Colors:
- _ClipRect: {r: -32767, g: -32767, b: 32767, a: 32767}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 84a92b25f83d49b9bc132d206b370281, type: 3}
m_Name: paladin
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TMP_SpriteAsset
m_Version: 1.1.0
m_FaceInfo:
m_FaceIndex: 0
m_FamilyName:
m_StyleName:
m_PointSize: 0
m_Scale: 0
m_UnitsPerEM: 0
m_LineHeight: 0
m_AscentLine: 0
m_CapLine: 0
m_MeanLine: 0
m_Baseline: 0
m_DescentLine: 0
m_SuperscriptOffset: 0
m_SuperscriptSize: 0
m_SubscriptOffset: 0
m_SubscriptSize: 0
m_UnderlineOffset: 0
m_UnderlineThickness: 0
m_StrikethroughOffset: 0
m_StrikethroughThickness: 0
m_TabWidth: 0
m_Material: {fileID: -4234567890123456789}
spriteSheet: {fileID: 2800000, guid: ce3bc31ab20668048b10a34c9d8f7c3b, type: 3}
m_SpriteCharacterTable:
- m_ElementType: 2
m_Unicode: 65534
m_GlyphIndex: 0
m_Scale: 1.25
m_Name: Paladin
m_GlyphTable:
- m_Index: 0
m_Metrics:
m_Width: 256
m_Height: 256
m_HorizontalBearingX: -128
m_HorizontalBearingY: 230
m_HorizontalAdvance: 256
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 256
m_Height: 256
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
sprite: {fileID: 21300000, guid: ce3bc31ab20668048b10a34c9d8f7c3b, type: 3}
spriteInfoList: []
fallbackSpriteAssets: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8b9e2010102a4d3297964885e3b286bb
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

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