Compare commits

...
Author SHA1 Message Date
admin 116d8681e2 Hide date for current province intelligence 2026-07-11 21:32:08 -07:00
adminandGitHub 09237eb9b1 Center province name over faction name (#8466)
* Center province header text

* Center province name independently of actions
2026-07-11 21:29:37 -07:00
adminandGitHub b15a9f0878 Scope Postgres save exports to a connection (#8469) 2026-07-11 20:25:13 -07:00
adminandGitHub a478906805 Preserve safe passage destination (#8468) 2026-07-11 20:20:33 -07:00
adminandGitHub 9619cb8076 Rename brotherhood quest to kinship (#8465) 2026-07-11 19:40:16 -07:00
adminandGitHub 588bcbd81f Improve Province Info under-attack alert (#8451)
* Improve Province Info attack alert

* Collapse inactive under-attack alert row

* Move under-attack alert above province info
2026-07-11 19:10:06 -07:00
adminandGitHub 16cacb9ee8 Add profession-specific stat requirements (#8464) 2026-07-11 18:51:07 -07:00
adminandGitHub 150970fcae Document Feast and Divine gold costs (#8463) 2026-07-11 17:50:45 -07:00
adminandGitHub de6ccb4345 Align in-memory history state boundaries (#8460) 2026-07-11 17:50:17 -07:00
adminandGitHub ab64052fa7 Document profession minimum stat (#8462) 2026-07-11 17:41:23 -07:00
adminandGitHub 38129c2a8e Add server tutorial dialogue payload (#8456)
* Drive tutorial opening dialogue from server

* Update tutorial dialogue contracts

* Keep tutorial rendering client-driven

* Make tutorial focus targets data-driven

* Reference tutorial speakers and client text
2026-07-11 14:13:20 -07:00
adminandGitHub 2fd16d962c Resize Please Recruit Me action buttons (#8457) 2026-07-11 13:53:22 -07:00
adminandGitHub 4dcb4bacdb Add battalion fundamentals guide (#8447)
* Add battalion fundamentals guide

* Explain battalion combat factors and types

* Link battalion mechanics to command references

* Explain armament-based archery volleys

* Remove prescriptive battalion checklist
2026-07-11 13:35:24 -07:00
adminandGitHub e1dd455af3 Add Shardok combat fundamentals (#8448)
* Add Shardok combat fundamentals

* Remove prescriptive Shardok decision sequence
2026-07-11 13:34:57 -07:00
adminandGitHub 1cd7474864 Print selected command templates (#8454) 2026-07-11 13:30:44 -07:00
adminandGitHub c1c60c406a Clarify Shardok victory timing (#8449) 2026-07-11 13:30:09 -07:00
adminandGitHub b20c8e55db Explain information visibility and recon (#8446) 2026-07-11 13:28:46 -07:00
adminandGitHub ca485e2c05 Add March and battle timeline (#8445) 2026-07-11 13:24:13 -07:00
adminandGitHub 1f44105ac4 Explain hero progression and profession rolls (#8450) 2026-07-11 12:24:23 -07:00
adminandGitHub b200d6d631 Document tactical activation follow-ups (#8453) 2026-07-11 12:23:50 -07:00
adminandGitHub 5ca66b9090 Explain Eagle turn progression (#8443)
* Explain Eagle turn progression

* Remove temporary command visibility section
2026-07-11 12:23:17 -07:00
adminandGitHub a714271ef2 Use active Shardok battles in text client (#8452) 2026-07-11 12:14:07 -07:00
adminandGitHub 21a311e9eb Explain command authority and province turns (#8441) 2026-07-11 09:43:40 -07:00
adminandGitHub 4b7a26b12b Document Eagle0 victory and defeat (#8440) 2026-07-11 09:39:09 -07:00
56 changed files with 1406 additions and 109 deletions
+14 -2
View File
@@ -37,7 +37,7 @@ commands
commands-json
```
`state` is the concise map and owned-province view. `state-json` prints the complete view. `commands-json` includes the exact fields, IDs, resource amounts, and command choices currently accepted by the server.
`state` is the concise map and owned-province view. `state-json` prints the complete view. `commands-json` includes the exact fields, IDs, resource amounts, and command choices currently accepted by the server. Each entry also includes a `post-json` template for its corresponding selected command.
Generated text is stored locally during the client process. Use `text <text-id>` for a text ID without spaces, such as `text hn_105`. Province leaders in `state` are resolved through this store when their names have been received.
@@ -49,6 +49,12 @@ Use the available-command data from `commands-json` to select a command, then po
post-json <province-id> <SelectedCommandType> <JSON>
```
Templates contain the accepted JSON field names and default values. Replace zero
IDs, `UNKNOWN` enum values, empty collections, and other placeholders with a legal
choice from the available-command payload before posting. Where a selected command
must echo server data, such as a ransom offer, the client copies that data into the
template automatically.
For example:
```text
@@ -57,7 +63,7 @@ post-rest 13
post-feast 13
```
The client has shortcuts for `post-rest`, `post-return`, `post-feast`, riot decisions, and battle aftermath. Other selected-command JSON schemas are defined in:
The client has shortcuts for `post-rest`, `post-return`, `post-feast`, riot decisions, and battle aftermath. The authoritative selected-command schemas are defined in:
```text
src/main/protobuf/net/eagle0/eagle/api/selected_command.proto
@@ -119,6 +125,12 @@ post-shardok <shardok-game-id> <index>
post-shardok <shardok-game-id> <index> <integer-roll>
```
The battle ID may be omitted only when exactly one outstanding battle currently
has postable commands. If multiple battles have commands, the client refuses the
ambiguous shorthand; use the battle ID printed by `shardok-commands`. Battles
removed from Eagle's outstanding-battle list are no longer postable, and a
Shardok update with no available commands clears the previous command list.
Use `shardok-commands-json` or `shardok-units-json` when the concise output omits a needed field.
## Reconnects And Shutdown
+6
View File
@@ -20,9 +20,14 @@ export default defineConfig({
label: "Concepts",
items: [
{ label: "Game Concepts", slug: "concepts" },
{ label: "Victory and Defeat", slug: "concepts/victory-and-defeat" },
{ label: "Who You Command", slug: "concepts/command-authority" },
{ label: "Eagle Round Flow", slug: "concepts/eagle-round-flow" },
{ label: "March and Battle Timeline", slug: "concepts/march-timeline" },
{ label: "Information and Recon", slug: "concepts/information-and-recon" },
{ label: "From Eagle to Shardok", slug: "concepts/eagle-to-shardok" },
{ label: "Shardok Battles", slug: "concepts/shardok-battles" },
{ label: "Shardok Combat Fundamentals", slug: "concepts/shardok-combat" },
{
label: "Shardok Victory Conditions",
slug: "concepts/shardok-victory-conditions",
@@ -32,6 +37,7 @@ export default defineConfig({
{ label: "Eagle Commands", slug: "commands" },
{ label: "Shardok Commands", slug: "shardok-commands" },
{ label: "Heroes", slug: "heroes" },
{ label: "Battalions", slug: "battalions" },
{ label: "Factions", slug: "factions" },
{ label: "Provinces", slug: "provinces" },
{ label: "Integration Notes", slug: "integration" },
@@ -0,0 +1,72 @@
---
title: Battalions
description: Troop count, type, training, armament, morale, heroes, and the battalion lifecycle.
---
Battalions are Eagle's organized troop formations and become units in [Shardok battles](/concepts/shardok-battles/) when attached to heroes.
## Reading a battalion
- **Troops** are its current soldiers. Casualties and starvation reduce this number; reaching zero destroys the battalion.
- **Type** determines movement, damage, battlefield limits, and whether an attached hero can cast or use stealth.
- **TRN (training)** increases the damage the battalion delivers. It is raised with [Train](/commands/#train).
- **ARM (armament)** reduces normal damage the battalion takes. It is raised with [Arm Troops](/commands/#arm-troops) and cannot exceed the province's effective infrastructure.
- **Morale** affects combat performance and gates many tactical actions. It changes through battle effects.
## How the combat factors work
These values do different jobs; they are not interchangeable ratings that simply add into one combat score.
- **Training** scales the battalion type's melee and missile damage bonuses. Higher training therefore means more damage per troop. It also affects some command-specific odds, including the chance to stun on a [Charge](/shardok-commands/#charge).
- **Armament** scales the battalion type's resistances and reduces incoming normal damage. Each type and damage category has its own resistance profile, so the same ARM value does not protect every battalion equally. Penetrating damage bypasses this normal armament reduction. If the unit can use [Archery](/shardok-commands/#archery), armament also determines its starting volleys: currently one volley per 10 ARM, rounded down, plus any bonus from its battalion type.
- **Morale** changes the battalion's damage swing. Most units also need at least 10 morale to use normal tactical commands.
- **Hero vigor** is an action constraint and current energy reserve, not battalion armor. A hero-bearing unit normally needs at least 10 vigor to act, and special effects can spend or reduce it.
- **Troop count** multiplies battalion damage: more surviving troops deliver more total battalion damage. It also determines how many soldiers remain available to absorb casualties.
- **Battalion type** supplies the base damage, training bonus, resistance profile, armament benefit, movement costs, charge multiplier, and special capabilities.
- **Hero stats** contribute separately. Charisma and bravery affect battalion combat swings, while a hero also contributes personal damage and enables stat- or profession-based commands.
Exact enemy training, armament, hero stats, or other details may be hidden. Use only the information your faction has revealed; the game does not promise that an unknown value is zero.
## Battalion types
### Light Infantry
The basic flexible foot formation. It is inexpensive, has the largest normal infantry capacity, can carry casting heroes, and allows [Ranger](/heroes/#ranger) stealth. Its protection gains from ARM are useful but lower than those of heavy formations.
### Heavy Infantry
Slower and more expensive to train and equip, with fewer troops per full battalion. Heavy Infantry starts with substantial physical resistance and generally gains more protection from ARM than light formations. It also delivers heavier melee damage, but cannot cast or hide.
### Light Cavalry
Fast on open terrain and especially effective when charging: its charge deals 1.2 times normal unit damage. A charge spends only the terrain-entry cost, so Light Cavalry may move or use another available command afterward when it retains AP.
### Heavy Cavalry
The most expensive normal formation, with the smallest capacity and highest food cost. It combines strong built-in resistance and armament scaling with the largest charge multiplier, 1.5. Like Light Cavalry, it can sometimes act again after charging if AP remains.
### Longbowmen
A ranged specialist that is always [archery](/shardok-commands/#archery)-capable and receives two bonus volleys in addition to those provided by ARM. It can also support casting and stealth-capable heroes. Longbow attacks include penetrating missile damage. Because penetrating damage bypasses normal armament reduction, trained Longbowmen can inflict heavy casualties even on well-armed troops. They are comparatively poorly protected by their own ARM.
### Undead
An exceptional formation created through necromantic effects rather than ordinary recruitment. Undead have high capacity, do not consume food or use normal morale, can cross ocean terrain, and are vulnerable to [Holy Wave](/shardok-commands/#holy-wave). Their combat and movement rules differ enough that they should not be evaluated like a normal recruitable battalion.
## Heroes and units
A Shardok unit normally combines one hero and one battalion. The hero supplies stats, vigor, profession commands, and special capabilities; the battalion supplies troops, type, training, armament, movement, morale, and damage profile. A profession alone may not be enough: casting requires a casting-capable battalion, and Ranger stealth requires a stealth-capable one.
## Creating and changing battalions
[**Organize Troops**](/commands/#organize-troops) creates, resizes, merges, transfers, or dismisses battalions. New troops cost gold and begin without training or armament. Transfers preserve weighted training and armament. Empty battalions are removed.
Use [**Train**](/commands/#train) to improve eligible battalions in the province and [**Arm Troops**](/commands/#arm-troops) while traveling to buy higher armament. Province economy and agriculture affect which types can be raised; effective infrastructure limits equipment.
## Movement, food, and battle
[March](/commands/#march) moves selected battalions with their heroes and supplies. Stationed and moving battalions consume food. A shortage spends the remaining food and can reduce battalions by up to 20 percent in one consumption event.
Battle results return surviving troop counts and statuses to Eagle. Battalions may survive with casualties, be destroyed, flee with their heroes, be captured/secured, or become the new garrison after conquest.
See [Organize Troops](/commands/#organize-troops), [Train](/commands/#train), [Arm Troops](/commands/#arm-troops), and [Shardok Battles](/concepts/shardok-battles/#units).
@@ -94,7 +94,9 @@ Most command phase actions spend the acting province's turn.
<b>Vigor cost:</b> None. This restores up to 10 vigor for each ruling-faction hero in the province.
<b>Available when:</b> the acting province can pay the feast cost: 20 gold per ruling-faction hero in the province, multiplied by the province [price index](/provinces/#price-index).
<b>Gold cost:</b> 20 gold for each ruling-faction hero in the province, multiplied by the province [price index](/provinces/#price-index) and rounded up. For example, a province with 3 ruling-faction heroes and a 1.25 price index pays 75 gold.
<b>Available when:</b> the acting province can pay the full feast cost.
<b>Effect:</b> Hold a feast in the acting province. Every ruling-faction hero in the province gains up to 10 loyalty, capped at 100, and up to 10 vigor, capped by constitution. Faction leaders do not gain loyalty, but can still gain vigor. The province spends gold, the price index may change, and feast-spending quest counters can advance.
@@ -323,9 +325,11 @@ You must use Travel before these commands appear. Once you are traveling, you ca
<b>Vigor cost:</b> None.
<b>Available when:</b> you are traveling, the province has gold, and there are free heroes who are not yet ready to join your faction but might be won over.
<b>Gold cost:</b> 100 gold per selected hero, multiplied by the province [price index](/provinces/#price-index) and rounded to the nearest whole gold for each hero. The total cost is that per-hero cost multiplied by the number of selected heroes. For example, divining 3 heroes at a 1.25 price index costs 375 gold.
<b>Effect:</b> Spend gold to divine one or more [free heroes](/factions/#free-heroes). For each divined hero, you receive details about what could impress them enough to become recruitable. Fulfill that ask, and the hero can later appear under Recruit Heroes. Cost scales by the province price index, the province spends gold, and the price index may change.
<b>Available when:</b> you are traveling, the province can pay the cost for at least one hero, and there are free heroes who are not yet ready to join your faction but might be won over.
<b>Effect:</b> Spend gold to divine one or more [free heroes](/factions/#free-heroes). For each divined hero, you receive details about what could impress them enough to become recruitable. Fulfill that ask, and the hero can later appear under Recruit Heroes. The province spends gold, and the price index may change.
<b>Selection:</b> choose the heroes to divine.
@@ -0,0 +1,62 @@
---
title: Who You Command
description: Faction leaders, vassals, province turns, and direct control.
---
Eagle commands are issued through provinces, but who chooses a province's action depends on its ruling hero. The key distinction is between **faction leaders**, whom the player commands directly, and **vassals**, who govern automatically.
## Leadership roles
| Role | Directly commanded? | Main purpose |
| --- | --- | --- |
| Faction head | Yes | Defines the faction and provides its original leadership |
| Other faction leader | Yes | Gives the player another directly controlled leader and a successor |
| Vassal | No | Rules a province according to standing orders and local needs |
| Unaffiliated hero | No | May be recruited, divined, apprehended, or otherwise interacted with |
The faction head is also a faction leader. **Swear Kinship** can promote a loyal faction hero into an additional faction leader. A faction leader who rules a province lets the player choose that province's normal command directly.
## Province turns
Most strategic actions belong to the province currently acting. During **Player Commands**, provinces led by faction leaders present their available commands to the player. A normal command such as Train, Improve, March, or Rest spends that province's turn.
The hero selected for a command is its actor, but selecting a different hero does not create another province turn. Several heroes may be eligible to perform the same province action; the player chooses which hero pays vigor and receives the associated experience.
Some commands explicitly do not take the turn. For example, **Issue Orders** changes standing instructions without consuming the acting province's normal action. The command reference labels these exceptions.
## Vassal-led provinces
A vassal-led province acts during **Vassal Commands**, before the main Player Commands phase. The vassal chooses an action using:
- the province's standing order: Entrust, Develop, Mobilize, or Expand;
- the faction's focus province;
- urgent local needs such as defense, riots, fatigue, low support, or beasts; and
- the commands actually available in the province.
Standing orders guide a vassal; they do not guarantee one exact command. A vassal may address a crisis before following a long-term development or expansion preference.
## Multiple leaders and provinces
Gaining more faction leaders does not give one province several normal actions. It expands direct control:
- A leader can directly rule a province instead of leaving it to a vassal.
- Leaders in different provinces can make those provinces player-controlled.
- Leaders provide succession if the faction head dies.
Heroes who are present but do not rule the province can still be selected for eligible commands. Province leadership determines who chooses the action, not which local hero must perform it.
## A simple example
Suppose your faction controls three provinces:
1. Your faction head rules the capital. You choose its action during Player Commands.
2. A second faction leader rules the frontier. You choose that province's action too.
3. A vassal rules a farming province with Develop orders. The vassal chooses its own action during Vassal Commands.
The result is two direct player decisions and one automatic vassal decision, assuming no riot, diplomacy offer, battle decision, or other special phase adds another required choice.
## Special decisions
Attack decisions, defense decisions, diplomacy responses, prisoner handling, riots, and battle aftermath are conditional decisions rather than extra normal province turns. They appear only when the current game state requires them and can pause the shared phase until every required faction responds.
See [Eagle Round Flow](/concepts/eagle-round-flow/) for phase timing and [Eagle Commands](/commands/) for which actions consume a turn.
@@ -30,6 +30,56 @@ Most rounds follow this broad rhythm:
Some phases are player-facing. Others are bookkeeping phases that advance automatically unless a special decision is needed.
## One Round Is One Month
The strategic date advances once per complete Eagle round, not once per command. A
command submitted during Player Commands belongs to the current month's round. The
server then continues through the remaining phases and starts the next round, whose
New Round processing advances the date by one month.
In a one-player game, the automatic phases may finish so quickly that it looks as
though the command itself advanced the month. If a decision or battle is pending,
the game remains in the same round and month until that work is complete.
## Phase Diagram
This diagram groups the detailed phases into their player-visible states:
```text
New Round and upkeep
|
v
Province movement and events
|
+----> Riot or recruitment decision? ----> wait for commands
|
v
Vassal Commands -> Player Commands --------------> wait for every required faction
|
v
Conquest setup -> Attack Decision -> Defense Decision
|
v
Battle Request and food use
|
v
Battle Resolution ----------> wait for all Shardok battles
|
v
Battle Aftermath -----------> wait for pending choices
|
v
Diplomacy Resolution -------> wait for pending replies
|
v
Recon Resolution
|
+-----------------> next month's New Round
```
"Wait" means the phase does not advance yet. The server may be waiting for your
choice, another faction's choice, or an external Shardok battle result.
## Automatic Upkeep
These phases usually advance without direct player input:
@@ -130,6 +180,8 @@ Diplomatic offers are resolved after players choose how to answer them. Offers c
## Simultaneous Information
For a focused explanation of public, hidden, and potentially stale observations, see [Information and Recon](/concepts/information-and-recon/).
The important habit is to think in phases, not in a fully visible turn order.
If two factions both issue orders during Player Commands, those orders belong to the same command phase. When the phase ends, each faction receives the filtered results it is allowed to observe. Your own actions are visible to you. Allied and nearby events may be visible depending on game state. Enemy actions may be hidden until scouts, battles, diplomacy, or later consequences reveal them.
@@ -19,6 +19,8 @@ Eagle is the strategic layer. Shardok is the tactical layer. The two connect whe
## Strategic Movement
For a compact round-by-round view of the entire process, see [March and Battle Timeline](/concepts/march-timeline/).
In Eagle, armies move through the March command. A moving army has an origin province, destination province, arrival round, heroes, battalions, and supplies.
Because Eagle is simultaneous, multiple factions can send armies during the same strategic phase. Those armies arrive later during movement resolution, after every relevant player has had the chance to submit orders for the phase.
@@ -7,10 +7,15 @@ These pages explain how Eagle0's major systems fit together. Use them when you w
## Eagle
- [Victory and defeat](victory-and-defeat/) explains the strategic objective, leadership succession, faction destruction, and how the game ends.
- [Who you command](command-authority/) explains faction heads, faction leaders, vassals, province turns, and direct control.
- [Eagle round flow](eagle-round-flow/) explains how a strategic round advances, why actions are simultaneous, and when players see results.
- [Information and recon](information-and-recon/) explains public, hidden, scouted, and potentially stale knowledge.
- [From Eagle to Shardok](eagle-to-shardok/) explains how strategic movement turns into tactical battles and how battle results return to Eagle.
- [March and battle timeline](march-timeline/) follows one army from order submission through arrival, battle, and aftermath.
## Shardok
- [Shardok combat fundamentals](shardok-combat/) explains turns, rounds, AP, movement, attacks, terrain, morale, and vigor.
- [Shardok battles](shardok-battles/) explains tactical setup, turns, units, morale, vigor, terrain, and battle aftermath.
- [Shardok victory conditions](shardok-victory-conditions/) explains how tactical battles end.
@@ -0,0 +1,46 @@
---
title: Information and Recon
description: Public, private, stale, and scouted information in simultaneous Eagle rounds.
---
Eagle does not give every faction a current view of the whole world. Results are filtered by province control, faction knowledge, scouting, diplomacy, alliances, and battle participation.
## Three kinds of uncertainty
- **Not present:** the game state confirms that something is absent.
- **Not visible:** it may exist, but your faction has not earned that information.
- **Known as of a date:** your faction observed it previously, but simultaneous actions may have changed it.
A blank enemy field can mean that the value is not visible; it does not necessarily mean the value is zero. An old observation describes the recorded date rather than guaranteeing the current state.
## Province information
Public province information can include identity, ruler, known events, known incoming armies, and an **as of** date. Full local information adds resources, caps, support, food consumption, battalions, heroes, orders, shipments, and events.
Controlling a province normally gives your faction its detailed state. Losing control does not mean every remembered fact remains current; the date tells you when the displayed knowledge was established.
## Simultaneous orders
Enemy factions choose during the same phase. Between an observation and a later arrival, a province may receive troops, change ruler, form a truce, or launch its own army. Observing a target does not reserve or freeze its state.
## Strategic Recon
Recon requires an eligible Ranger and targets a province not ruled by the acting faction. The command creates pending reconnaissance rather than instantly granting omniscience. Results are applied during **Recon Resolution**, after the relevant strategic activity.
Recon improves what your faction knows about the target at that point in the round. Later actions can make those details stale. The observation date records when that knowledge was established.
## Other ways information is earned
- Ruling a province exposes its local state.
- Battle participants learn information revealed by the tactical encounter and its result.
- Diplomacy reveals offers and relationship changes to the involved factions.
- Allied or nearby events may be visible when the game state permits it.
- Moving next to, scouting, or revealing hidden Shardok units affects tactical knowledge separately from strategic Recon.
Alliance does not make every hidden fact in the world public. Each faction can still have knowledge that the other does not.
## Example
In round 8, Recon reports an enemy frontier province with one battalion. You March toward it during round 9. The enemy simultaneously sends reinforcements. Your report was truthful as of round 8, but it did not promise that the round-9 defenders would be unchanged.
See [Eagle Round Flow](/concepts/eagle-round-flow/#simultaneous-information), [Recon](/commands/#recon), and [Provinces](/provinces/) for related rules.
@@ -0,0 +1,52 @@
---
title: March and Battle Timeline
description: Where an army is from order submission through arrival and battle aftermath.
---
March is a delayed strategic order. Selected heroes, battalions, gold, and food leave the origin immediately, but they do not enter the destination until later processing.
## The timeline
| When | State | What happens |
| --- | --- | --- |
| Player Commands, round N | March submitted | Forces and supplies leave the origin and form a moving army scheduled for round N+1. Each marching hero spends vigor. |
| Province Move Resolution, round N+1 | Arrival checked | An army already friendly to the destination enters it. A hostile arrival waits outside rather than entering. |
| After Player Commands, round N+1 | Hostile Army Setup | Hostile arrivals become attacking groups, one per faction, preserving their approach direction and carried supplies. |
| Uncontested Conquest | No tactical defense needed | An unruled destination can be claimed without Shardok. Competing groups are compared by size, power, and a deterministic tie-breaker. |
| Attack and Defense Decisions | A ruled province is contested | Attackers decide whether to press the attack; defenders may choose an army and fallback destination. Truces can turn armies back. |
| Battle Request and Resolution | Shardok battle | Eagle sends armies, map, limited battle food, and victory conditions to Shardok, then waits for a result. |
| Battle Aftermath | Consequences applied | Eagle resolves ownership, survivors, prisoners, supplies, devastation, prestige, and any allied-claimant decisions. |
## Friendly arrival
If your faction already rules the destination during Province Move Resolution, the army's heroes, battalions, and supplies enter the province. They are then present before later phases such as player commands and food consumption.
## Neutral or hostile arrival
A marching army does not occupy a neutral or enemy province during movement resolution. It remains a moving/hostile force until conquest or battle processing decides the outcome. This distinction matters for hero location, province leadership, and food.
An unruled province may be conquered without a tactical battle. A ruled and defended province can require attack and defense decisions followed by Shardok.
## Food and gold
Carried supplies travel with the army. Moving armies consume their own food while incoming. If food is insufficient, their battalions can suffer starvation losses.
When a normal province battle is created, attackers bring at most one month of food consumption from their carried food. Defenders receive at most one month from province food, removed when the battle is created. Remaining attacking supplies are applied to the battle province during resolution; whoever ultimately controls the province may therefore inherit them.
## One attacker wins
One victorious attacking faction conquers the province. Its surviving, non-fled heroes and battalions become the new ruling force, support becomes 0, orders reset to Entrust, and eligible defenders can become prisoners.
## Allied attackers win together
Eagle cannot automatically choose which allied winner keeps the province. Claimants enter Battle Aftermath. One may keep it; the others withdraw to adjacent provinces with permitted shares of the supplies they brought. Those withdrawals become incoming armies for the next round.
## Before confirming March
- Confirm the destination and arrival delay.
- Leave enough heroes behind to retain the origin if necessary.
- Carry enough food for transit and the intended battle.
- Remember that the destination's ruler or diplomacy can change before arrival.
- Expect an additional attack, defense, or aftermath decision when the army arrives.
See [March](/commands/#march) for selection rules and [From Eagle to Shardok](/concepts/eagle-to-shardok/) for detailed battle consequences.
@@ -14,6 +14,8 @@ Source map:
Shardok is the tactical battle layer. Eagle decides that a battle should happen, sends armies into Shardok, and later receives the result back.
For the decision-making basics behind the command list, see [Shardok Combat Fundamentals](/concepts/shardok-combat/).
## Setup
At the start of a battle, each side places its units onto valid setup hexes. Some battles include reserve or reinforcement units that enter later. Rangers on stealth-capable units may be able to deploy hidden.
@@ -22,6 +24,8 @@ The map comes from the province where the battle occurs. A province's Shardok ma
## Units
See [Battalions](/battalions/) for troop, type, training, armament, morale, and lifecycle fundamentals.
A Shardok unit usually represents a hero attached to a battalion. The hero brings stats, vigor, profession abilities, and special command access. The battalion brings troop count, type, morale, training, armament, movement costs, damage profile, and battlefield limits.
Some commands require a profession. Some also require the attached battalion type to support that role. For example, many spell commands require a unit that can cast, while Ranger stealth depends on a stealth-capable battalion.
@@ -0,0 +1,34 @@
---
title: Shardok Combat Fundamentals
description: Turns, rounds, Action Points, movement, attacks, terrain, morale, and vigor.
---
Shardok is an objective-based hex battle, not a direct comparison of troop totals. Position, terrain, heroes, hidden information, and command timing determine which force can satisfy its victory conditions.
## Turns, units, and rounds
Players act according to the tactical state. A unit spends **Action Points (AP)** to move or use commands. Ending the player turn sets that player's remaining unit AP to 0. After every required side has had its opportunity, the tactical round closes and end-of-round victory conditions are checked.
## Movement and position
Movement cost depends on terrain and battalion type. Water, mountains, bridges, ice, fire, castles, and cities can restrict paths or change their value. Enemy proximity can affect hiding and movement choices. Use the highlighted valid targets rather than assuming every adjacent hex is traversable.
## Attacks and special actions
Melee and ranged attacks require valid targets and battlefield geometry. Line of fire can be blocked. Damage and resistance depend on the units, heroes, damage profiles, and terrain involved.
Many major actions require a minimum AP and then consume all remaining AP. The command reference states the exact rule. Unless an entry says otherwise, a morale-using unit needs at least 10 morale, and a unit with a hero needs at least 10 hero vigor.
## Heroes and battalions
The battalion provides troops, type, morale, training, armament, movement, and damage behavior. The attached hero provides stats, vigor, profession abilities, and special commands. Profession commands can also require a compatible battalion: casting and Ranger stealth are important examples.
## Terrain and objectives
Castles and cities may be defensive positions or critical objectives. Critical-tile victory normally requires a hero-bearing unit on every required tile at the end of the tactical round. Occupying an objective during your turn is therefore not always immediate victory; opponents may receive a chance to respond.
## Hidden units
Hidden units are not fully visible. Ranger scouting, adjacency, movement, and terrain can reveal them. Hidden units do not keep a player alive for Last Player Standing, although a capable hidden loser may flee when battle ends.
See [Shardok Commands](/shardok-commands/) for exact action requirements and [Shardok Victory Conditions](/concepts/shardok-victory-conditions/) for battle-ending checks.
@@ -14,6 +14,28 @@ Shardok battles end when one of the battle's victory conditions is satisfied. Ea
In a normal Eagle province assault, attackers usually get **Hold Critical Tiles**, **Last Player Standing**, and **Last Alliance Standing**. The defender usually gets **Last Player Standing** and **Win After Max Rounds**.
## When conditions are checked
A **turn** is one player's opportunity to act. A **round** includes the required turns before end-of-round processing. This distinction determines whether the opposing side gets a response.
| Condition | Typical holder | Check timing |
| --- | --- | --- |
| Last Player Standing | Attacker and defender | As soon as only one eligible player survives |
| Last Alliance Standing | Allied attackers | As soon as the only eligible survivors are mutually allied holders |
| Hold Critical Tiles | Attackers | End of the tactical round |
| Win After Max Rounds | Defender | End-of-round clock check |
| Most Troops Standing | Special battle types | At its configured ending check |
The battle display's assigned conditions and maximum-round value are authoritative for that battle; the normal-assault assignments above are defaults, not promises for every scenario.
### Critical-tile response example
An attacker occupies the final critical tile during their turn. The battle does not end at that movement step. The defender receives the remaining opportunity provided by the tactical round and can retake or contest an objective. If eligible allied attackers still occupy every critical tile when the round closes, Hold Critical Tiles succeeds.
### Defender-clock example
The defender does not need to eliminate every attacker when Win After Max Rounds is assigned. The attacker must satisfy an attacking victory condition before the displayed round limit expires. Surviving without taking the objectives can therefore be a loss for the attacker.
## Last Player Standing
A player wins if they are the only surviving player and they have this victory condition.
@@ -0,0 +1,62 @@
---
title: Victory and Defeat
description: How factions win, survive leadership losses, and leave the game.
---
Eagle0 is a struggle to become the last surviving faction. The game ends when only one faction remains; that faction wins. If no factions remain, the game ends without a winner.
## When a faction survives
A faction remains in the game while it has both:
- at least one faction leader; and
- at least one ruled province or moving army.
Losing a province is therefore serious, but it is not necessarily immediate defeat. An army already marching can keep a landless faction alive long enough to conquer somewhere else.
## Losing your faction head
The death of the faction head does not automatically destroy the faction. Another surviving faction leader succeeds them.
If no faction leader is available but the faction still has a province or moving army, the strongest surviving vassal can rise to become both faction leader and faction head. The faction is destroyed only if no vassal can succeed.
Creating additional leaders with **Swear Kinship** protects the faction against a sudden leadership loss. A candidate must belong to the faction, have at least 95 loyalty, and be with the faction head when the command is used.
## How a faction is destroyed
A faction is destroyed when either condition is true:
1. It rules no provinces and has no moving armies.
2. It has no faction leaders after the succession check.
When a faction is destroyed:
- its provinces become unruled;
- its incoming armies are removed and their supplies remain at their destinations;
- its heroes lose their faction assignment; and
- diplomacy and outstanding offers involving it are removed.
Those heroes are not necessarily killed. They may remain in the world in unaffiliated states and interact with surviving factions later.
## Strategic implications
- Keep a succession option by gaining another leader or maintaining a capable vassal.
- Preserve at least one province or viable moving army.
- Do not assume an enemy is eliminated merely because its last visible province falls; it may still have an army in motion.
- Remember that conquest can release, capture, or displace heroes without destroying their former faction immediately.
## Quick answers
**Do I win a battle by killing the enemy faction head?**
Not automatically. Leadership succession may keep the faction alive.
**Am I defeated as soon as I lose my last province?**
Not if your faction still has a moving army and a leader.
**Can a faction with provinces survive after all its leaders die?**
Only if a surviving vassal can rise during the succession check.
**How does the whole game end?**
The final surviving faction wins. If every faction is destroyed, there is no winner.
For tactical battle victory rules, see [Shardok Victory Conditions](/concepts/shardok-victory-conditions/). For leadership roles, see [Heroes](/heroes/#faction-leaders) and [Factions](/factions/#leaders).
@@ -88,6 +88,8 @@ A faction can have a focus province. The focus province helps direct [province o
## Faction Destruction
For a player-oriented overview of winning, losing, and leadership succession, see [Victory and Defeat](/concepts/victory-and-defeat/).
A faction is destroyed if either of these is true:
- It has no ruled provinces and no moving armies.
@@ -34,6 +34,8 @@ Vigor is a hero's current energy. Many commands require an acting hero and spend
Loyalty measures a hero's attachment to their faction. Feasts, events, captures, faction bias, and story consequences can move loyalty over time.
Loyalty is not faction bias. Loyalty describes an affiliated hero's current attachment; bias helps determine whether an unaffiliated or imprisoned hero will join a particular faction.
## Professions
Professions give heroes special command access, bonuses, and tactical identity. Some effects are Eagle strategic effects, while others apply only when the hero is attached to a Shardok battle unit.
@@ -179,15 +181,31 @@ Heroes with no profession have no profession-specific command unlocks. They can
Only heroes with **NoProfession** can gain a profession. At the end of a round, heroes with enough XP can gain stats. If a no-profession hero gains one or more stats, the game checks for profession gain:
- The hero gets one profession-gain roll for each stat that increased.
- Eligible professions are based on all post-gain stats at or above the configured prime-stat threshold.
- A stat must be at least **85 after the stat gains are applied** to qualify a profession.
- Eligible professions are based on all post-gain stats at or above 85, not only the stats that increased that round.
- Strength qualifies **Champion**.
- Agility qualifies **Engineer** and **Ranger**.
- Wisdom qualifies **Mage**.
- Charisma qualifies **Necromancer** and **Paladin**.
- Constitution qualifies **Warden**.
### Progression example
A NoProfession hero earns Agility-related XP from strategic actions. During end-of-round progression, enough XP may increase Agility. If the hero's Agility is at least 85 after that increase, Engineer and Ranger are eligible. The hero receives profession-gain rolls for stats that increased; the player does not directly choose between eligible professions.
If several stats increase together, each increase can create a roll and all post-gain qualifying stats participate in eligibility. A hero who already has a profession does not roll for a replacement.
### Planning progression
- Commands award XP in stats associated with the action.
- Stat increases and profession checks happen during end-of-round processing, not when XP is awarded.
- Reaching 85 in a qualifying stat makes its profession possible but does not guarantee a particular random result.
- Choosing which eligible hero performs a command influences who receives its XP.
## Faction Leaders
For a side-by-side explanation of direct control, province turns, and vassal automation, see [Who You Command](/concepts/command-authority/).
Every faction starts with a **faction head**. The faction head is the hero who defines what the faction represents: its political identity, its public story, and the starting point for its leadership.
Factions can gain additional faction leaders through **Swear Kinship** with another hero. The candidate must already belong to the faction, must have at least 95 loyalty, and must be in a province with the faction head. When the command succeeds, that hero becomes one of the faction's leaders.
@@ -7,6 +7,8 @@ Eagle0pedia is the in-game reference companion for Eagle0. The tutorial answers
Eagle0 is a strategy game about factions, heroes, provinces, armies, diplomacy, and tactical battles. The broad strategic layer is **Eagle**: provinces produce food and gold, heroes lead factions or rule as vassals, armies march across the map, and diplomacy shapes the war. When armies clash, battles are resolved in **Shardok**, the tactical hex-based combat system.
Your strategic goal is to become the last surviving faction. Start with [Victory and Defeat](concepts/victory-and-defeat/) to learn how factions win, survive leadership losses, and leave the game.
Use these pages when you want a quick reference for commands, hero professions, faction leadership, province stats, or tactical battle options.
## Sections
@@ -19,5 +21,6 @@ Use these pages when you want a quick reference for commands, hero professions,
- [Eagle commands](commands/) explains the strategic commands available from province command panels.
- [Shardok commands](shardok-commands/) explains tactical battle commands and profession-gated battlefield actions.
- [Heroes](heroes/) covers stats, vigor, loyalty, professions, faction leaders, and battle capabilities.
- [Battalions](battalions/) covers troop count, type, training, armament, morale, heroes, and battlefield outcomes.
- [Factions](factions/) covers faction heads, leaders, prestige, diplomacy, and focus provinces.
- [Provinces](provinces/) covers province stats, supplies, orders, armies, maps, castles, and unaffiliated heroes.
@@ -47,6 +47,18 @@ Source map for Shardok command requirements/costs:
Unless noted otherwise: **10 morale**, **10 hero vigor**, and the listed **Action Point cost**.
## Activations and follow-ups
A unit can keep acting while it has Action Points (AP) and at least one available command. Read each command's AP line as follows:
- **Consumes all remaining AP** or **sets the unit to 0 AP** means **ends activation**. The unit cannot act again this turn.
- A command with only an exact AP cost spends that amount. If the unit still has AP afterward, its activation can continue with whatever commands are then available.
- A command may also end the unit's useful activation because no legal commands remain, even if the display still shows AP.
Movement is the most important case. Each destination shows its movement cost and may show **follow-up available** icons. Those follow-ups are actions predicted to be legal *after reaching that destination*, such as Charge, Scout, Start Fire, Repair, Reduce, or Reinforce.
The follow-up list is a planning aid, not a complete list of everything the unit might do next. It does not include another Move, and battlefield information can change when the move resolves. A destination with no follow-up icons does not itself mean “ends activation”: compare the movement cost with the unit's remaining AP and check the commands offered after arrival.
## Tactical Commands
### Melee
@@ -79,6 +91,8 @@ Unless noted otherwise: **10 morale**, **10 hero vigor**, and the listed **Actio
<b>Action Point cost:</b> the cost to enter the target's terrain. Knights and Dragoons spend only that terrain cost and may be able to move again afterward; other units spend all remaining AP.
<b>Activation:</b> ends activation for units that spend all remaining AP. Knights and Dragoons can continue when they retain AP and another command is available.
<b>Available when:</b> a valid path reaches a target that can be attacked at the end of the move.
<b>Effect:</b> Move into an attack as one action. Charge attacks have a chance to stun the target unit.
@@ -91,6 +105,8 @@ Unless noted otherwise: **10 morale**, **10 hero vigor**, and the listed **Actio
<b>Action Point cost:</b> the terrain cost for each hex in the path, plus 1 extra AP for steps that start in enemy zone of control.
<b>Activation:</b> does not inherently end activation. The unit can continue if it has AP and a legal command after arriving. Follow-up icons on a destination preview eligible non-move actions there.
<b>Available when:</b> one or more reachable destination hexes are valid for the acting unit.
<b>Effect:</b> Move the unit along the chosen path to a reachable hex, paying terrain costs as it goes. Hidden units may reveal themselves if they move into exposed terrain or near an enemy Ranger.
@@ -789,7 +789,7 @@ MonoBehaviour:
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 80
m_PreferredHeight: 30
m_PreferredHeight: 48
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -3989,7 +3989,7 @@ MonoBehaviour:
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 80
m_PreferredHeight: 30
m_PreferredHeight: 48
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -4749,7 +4749,7 @@ MonoBehaviour:
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 80
m_PreferredHeight: 30
m_PreferredHeight: 48
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -53,6 +53,8 @@ namespace eagle {
public TMP_Text goldLabel;
public TMP_Text foodLabel;
private ProvinceId _safePassageDestinationProvinceId;
private AttackDecisionAvailableCommand AttackDecisionCommand =>
_availableCommand.AttackDecisionCommand;
@@ -67,7 +69,12 @@ namespace eagle {
if (withdrawToggle.isOn)
return new AttackDecisionType { Withdraw = new WithdrawDecision() };
if (safePassageToggle.isOn)
return new AttackDecisionType { SafePassage = new SafePassageDecision() };
return new AttackDecisionType {
SafePassage =
new SafePassageDecision {
ToProvinceId = _safePassageDestinationProvinceId
}
};
throw new Exception("No decision selected");
}
@@ -141,6 +148,7 @@ namespace eagle {
break;
case AttackDecisionType.SealedValueOneofCase.SafePassage:
enableSafePassage = true;
_safePassageDestinationProvinceId = ad.SafePassage.ToProvinceId;
break;
}
}
@@ -9745,6 +9745,7 @@ GameObject:
- component: {fileID: 296148958613372072}
- component: {fileID: 1445788806017443158}
- component: {fileID: 3391666280041911029}
- component: {fileID: 900000000000000006}
m_Layer: 0
m_Name: Standard Left Column
m_TagString: Untagged
@@ -9764,6 +9765,7 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 900000000000000002}
- {fileID: 8450970734016342464}
- {fileID: 5448146937309441081}
- {fileID: 6624731758075026335}
@@ -13734,3 +13736,177 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 11e6c1f6549ca4d3b8f3b8d21085e5b2, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &900000000000000001
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 900000000000000002}
- component: {fileID: 900000000000000005}
- component: {fileID: 900000000000000003}
- component: {fileID: 900000000000000004}
m_Layer: 0
m_Name: Province Under Attack Alert
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!224 &900000000000000002
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 900000000000000001}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 8194435045387419553}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &900000000000000003
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 900000000000000001}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.55, g: 0.05, b: 0.04, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: UNDER ATTACK!
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278190335
m_fontColor: {r: 0.55, g: 0.05, b: 0.04, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 700
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 1
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &900000000000000004
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 900000000000000001}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: 34
m_PreferredWidth: -1
m_PreferredHeight: 34
m_FlexibleWidth: 1
m_FlexibleHeight: 0
m_LayoutPriority: 1
--- !u!222 &900000000000000005
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 900000000000000001}
m_CullTransparentMesh: 0
--- !u!114 &900000000000000006
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7089574085975417824}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9e3a7cbe4e1f4e59a2a57ea40c9f5e29, type: 3}
m_Name:
m_EditorClassIdentifier:
provinceInfoPanelController: {fileID: 5448146937309441222}
alertPanel: {fileID: 900000000000000001}
alertText: {fileID: 900000000000000003}
@@ -165,7 +165,7 @@ MonoBehaviour:
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 0
m_ChildAlignment: 1
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
@@ -790,12 +790,12 @@ GameObject:
- component: {fileID: 159964988}
- component: {fileID: 159964989}
m_Layer: 0
m_Name: Under Attack Field
m_Name: Legacy Under Attack Field
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 2147483647
m_IsActive: 1
m_IsActive: 0
--- !u!114 &159964988
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -8590,7 +8590,7 @@ RectTransform:
m_Father: {fileID: 1688258463}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
@@ -8606,7 +8606,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_IgnoreLayout: 1
m_MinWidth: -1
m_MinHeight: 30
m_PreferredWidth: -1
@@ -16,6 +16,9 @@ namespace eagle {
public class ProvinceInfoPanelController : MonoBehaviour {
private IGameModel _model;
private bool underAttack;
public bool UnderAttack => underAttack;
public event Action<bool> UnderAttackChanged;
public IGameModel Model { private
get => _model;
set {
@@ -50,7 +53,6 @@ namespace eagle {
public GameObject ProvinceDetailsContainer;
public TextMeshProUGUI DiplomacyInfoField;
public TextMeshProUGUI AsOfField;
public TextMeshProUGUI UnderAttackField;
public TextMeshProUGUI GoldField;
public TextMeshProUGUI FoodField;
@@ -221,11 +223,8 @@ namespace eagle {
x => x.FactionId != Model.PlayerId && x.HeroCount > 0);
AsOfField.gameObject.SetActive(false);
if (ownProvince) {
panel.GetComponent<Image>().color =
underAttack ? GUIUtils.UnderAttackPanelColor : GUIUtils.PanelColor;
UnderAttackField.gameObject.SetActive(underAttack);
}
panel.GetComponent<Image>().color = GUIUtils.PanelColor;
SetUnderAttack(underAttack);
if (fullInfo.ProvinceOrders != ProvinceOrderType.UnknownOrders) {
AsOfField.gameObject.SetActive(false);
@@ -237,7 +236,7 @@ namespace eagle {
mobilizeImage.gameObject.SetActive(true);
break;
}
} else if (Province.AsOf != null) {
} else if (Province.AsOf != null && !Province.AsOf.Equals(Model.CurrentDate)) {
AsOfField.text = $"As of {DisplayNames.DateString(Province.AsOf)}";
AsOfField.gameObject.SetActive(true);
}
@@ -302,6 +301,7 @@ namespace eagle {
DiplomacyInfoField.gameObject.SetActive(false);
ProvinceDetailsContainer.SetActive(false);
supportAndIndexContainer.SetActive(false);
SetUnderAttack(false);
entrustImage.gameObject.SetActive(false);
developImage.gameObject.SetActive(false);
@@ -423,6 +423,13 @@ namespace eagle {
}
}
private void SetUnderAttack(bool value) {
if (underAttack == value) { return; }
underAttack = value;
UnderAttackChanged?.Invoke(underAttack);
}
private void OnDestroy() { factionTextUpdater?.Dispose(); }
}
}
@@ -0,0 +1,37 @@
using TMPro;
using UnityEngine;
namespace eagle {
public class ProvinceUnderAttackAlertController : MonoBehaviour {
private static readonly Color UnderAttackTextColor = new(0.55f, 0.05f, 0.04f);
private static readonly Color UnderAttackTextPulseColor = new(1.0f, 0.2f, 0.08f);
[SerializeField]
private ProvinceInfoPanelController provinceInfoPanelController;
[SerializeField]
private GameObject alertPanel;
[SerializeField]
private TextMeshProUGUI alertText;
private void Awake() {
provinceInfoPanelController.UnderAttackChanged += SetUnderAttack;
SetUnderAttack(provinceInfoPanelController.UnderAttack);
}
private void OnDestroy() {
provinceInfoPanelController.UnderAttackChanged -= SetUnderAttack;
}
private void Update() {
if (!alertPanel.activeSelf) { return; }
float pulse = (Mathf.Sin(Time.unscaledTime * Mathf.PI * 2f / 1.2f) + 1f) * 0.5f;
alertText.color = Color.Lerp(UnderAttackTextColor, UnderAttackTextPulseColor, pulse);
}
private void SetUnderAttack(bool underAttack) {
alertPanel.SetActive(underAttack);
if (underAttack) { alertText.color = UnderAttackTextColor; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e3a7cbe4e1f4e59a2a57ea40c9f5e29
@@ -176,8 +176,8 @@ namespace eagle {
var brotherhoodDetails = quest.Details.SwearBrotherhoodWithHeroQuest;
SetDynamicHeroQuestText(
brotherhoodDetails.TargetHeroId,
(heroName) => $"Swear brotherhood with {heroName}",
"Swear brotherhood with a hero",
(heroName) => $"Swear kinship with {heroName}",
"Swear kinship with a hero",
model);
break;
@@ -404,7 +404,7 @@ namespace eagle {
return "Release all prisoners (don't execute, move, or trade them)";
case SealedValueOneofCase.SwearBrotherhoodWithHeroQuest:
return "Swear brotherhood with a hero";
return "Swear kinship with a hero";
case SealedValueOneofCase.BetrayAllyQuest: {
var details = quest.Details.BetrayAllyQuest;
@@ -44,8 +44,6 @@ namespace EagleGUIUtils {
public static readonly GUIStyle HeaderStyle;
public static readonly Color PanelColor;
public static readonly GUIStyle PanelStyle;
public static readonly Color UnderAttackPanelColor;
public static readonly GUIStyle UnderAttackPanelStyle;
private static Texture2D fromColor(Color color) {
Color[] pix = new Color[1];
@@ -79,10 +77,6 @@ namespace EagleGUIUtils {
PanelColor = new Color(1.0f, 1.0f, 1.0f, 0.25f);
PanelStyle = new GUIStyle();
PanelStyle.normal.background = fromColor(PanelColor);
UnderAttackPanelColor = Color.magenta;
UnderAttackPanelStyle = new GUIStyle();
UnderAttackPanelStyle.normal.background = fromColor(UnderAttackPanelColor);
}
public static void CenteredLabel(string text, GUIStyle style) {
@@ -178,4 +172,4 @@ namespace EagleGUIUtils {
return $"<color={colorString}>{province.Name}</color>";
}
}
}
}
@@ -20,6 +20,7 @@ proto_library(
visibility = ["//visibility:public"],
deps = [
":game_state_view_proto",
":tutorial_dialogue_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_proto",
"@com_google_protobuf//:wrappers_proto",
@@ -268,6 +269,7 @@ csharp_proto_compile(
":province_view_proto",
":shardok_battle_view_proto",
":stat_with_condition_proto",
":tutorial_dialogue_proto",
],
visibility = ["//src/main/csharp/net/eagle0/clients/unity/eagle0:__pkg__"],
)
@@ -287,6 +289,7 @@ go_proto_library(
":province_view_proto",
":shardok_battle_view_proto",
":stat_with_condition_proto",
":tutorial_dialogue_proto",
],
visibility = ["//visibility:public"],
deps = [
@@ -294,3 +297,18 @@ go_proto_library(
"//src/main/protobuf/net/eagle0/eagle/common:common_go_proto",
],
)
proto_library(
name = "tutorial_dialogue_proto",
srcs = ["tutorial_dialogue.proto"],
visibility = ["//visibility:public"],
)
scala_proto_library(
name = "tutorial_dialogue_scala_proto",
visibility = [
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [":tutorial_dialogue_proto"],
)
@@ -10,6 +10,7 @@ import "google/protobuf/wrappers.proto";
import "src/main/protobuf/net/eagle0/eagle/common/action_result_notification_details.proto";
import "src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto";
import "src/main/protobuf/net/eagle0/eagle/views/game_state_view.proto";
import "src/main/protobuf/net/eagle0/eagle/views/tutorial_dialogue.proto";
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.views";
@@ -26,4 +27,6 @@ message ActionResultView {
GameStateViewDiff game_state_diff = 5;
repeated .net.eagle0.eagle.common.Notification notifications = 6;
repeated TutorialDialogue tutorial_dialogues = 7;
}
@@ -0,0 +1,36 @@
syntax = "proto3";
package net.eagle0.eagle.views;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.views";
option java_outer_classname = "TutorialDialogueProto";
option objc_class_prefix = "E0G";
// Tutorial copy is server-owned. Clients may interpret focus as a presentation
// hint, but should render the speaker and text directly from this payload.
message TutorialDialogue {
string id = 1;
repeated TutorialDialogueStep steps = 2;
}
message TutorialDialogueStep {
int32 speaker_hero_id = 1;
string dialogue_text_id = 2;
optional string instruction_text_id = 3;
TutorialFocus focus = 5;
}
message TutorialFocus {
oneof target {
int32 province_id = 1;
string button_id = 2;
TutorialHexTile hex_tile = 3;
int32 unit_id = 4;
}
}
message TutorialHexTile {
int32 row = 1;
int32 column = 2;
}
@@ -27,6 +27,15 @@ filegroup(
],
)
filegroup(
name = "tutorial_dialogue_text",
srcs = ["tutorial_dialogue_text.tsv"],
visibility = [
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/test:__subpackages__",
],
)
filegroup(
name = "beasts",
srcs = ["beasts.tsv"],
@@ -275,7 +275,13 @@ priceIndexReturnRate 0.1 Double
priceIndexShiftPerGold 0.0001 Double
aiTruceAcceptanceBaseChance 100 Double
aiAllianceAcceptanceBaseChance 100 Double
primeStatMinForProfession 85 Int
minStrengthForChampionProfession 85 Int
minAgilityForEngineerProfession 85 Int
minWisdomForMageProfession 85 Int
minCharismaForNecromancerProfession 85 Int
minCharismaForPaladinProfession 85 Int
minAgilityForRangerProfession 85 Int
minConstitutionForWardenProfession 94 Int
professionGainChance 0.07 Double
llmProvider gemini String
openAiModelName gpt-5-mini String
1 actionVigorCost 15 Int
275 priceIndexShiftPerGold 0.0001 Double
276 aiTruceAcceptanceBaseChance 100 Double
277 aiAllianceAcceptanceBaseChance 100 Double
278 primeStatMinForProfession minStrengthForChampionProfession 85 Int
279 minAgilityForEngineerProfession 85 Int
280 minWisdomForMageProfession 85 Int
281 minCharismaForNecromancerProfession 85 Int
282 minCharismaForPaladinProfession 85 Int
283 minAgilityForRangerProfession 85 Int
284 minConstitutionForWardenProfession 94 Int
285 professionGainChance 0.07 Double
286 llmProvider gemini String
287 openAiModelName gpt-5-mini String
@@ -0,0 +1,5 @@
text_id text
tutorial_opening_dialogue_1 Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant — 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 — not against the Crown, but against the man who wields its sword and can no longer be trusted with it.
tutorial_opening_dialogue_2 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.
tutorial_opening_dialogue_3 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.
tutorial_opening_instruction Click <b>Fight!</b> to enter tactical combat.
1 text_id text
2 tutorial_opening_dialogue_1 Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant — 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 — not against the Crown, but against the man who wields its sword and can no longer be trusted with it.
3 tutorial_opening_dialogue_2 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.
4 tutorial_opening_dialogue_3 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.
5 tutorial_opening_instruction Click <b>Fight!</b> to enter tactical combat.
@@ -8,11 +8,12 @@ import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.view_filters.GameStateViewFilter
import net.eagle0.eagle.library.util.GameStateViewDiffer
import net.eagle0.eagle.model.action_result.types.ActionResultType
import net.eagle0.eagle.model.state.{GameType, RoundPhase, TutorialPhase}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.model.view.action_result.ActionResultView
import net.eagle0.eagle.model.view.action_result.{ActionResultView, TutorialDialogue, TutorialDialogueContent}
import net.eagle0.eagle.model.view.game_state.{GameStateView, GameStateViewDiff}
import net.eagle0.eagle.FactionId
import net.eagle0.util.hero_generation.LoadedHero
object ActionResultFilter {
private val UNIVERSALLY_VISIBLE_TYPES: Set[ActionResultType] = Set(
@@ -107,6 +108,8 @@ object ActionResultFilter {
def shouldIncludeNotification(targetFactionIds: Vector[FactionId]): Boolean =
targetFactionIds.isEmpty || factionId.exists(targetFactionIds.contains)
val tutorialDialogues = openingTutorialDialogue(actionResult.actionResultType, result.resultingState, factionId)
if gsDiff.isDefined &&
actionResult.actingFactionId.isDefined &&
factionId.exists { askingFid =>
@@ -137,7 +140,8 @@ object ActionResultFilter {
provinceId = actionResult.provinceId,
actingHeroId = actionResult.actingHeroId,
gameStateDiff = gsDiff,
notifications = filteredNotifications
notifications = filteredNotifications,
tutorialDialogues = tutorialDialogues
)
}
}
@@ -195,6 +199,9 @@ object ActionResultFilter {
def shouldIncludeNotification(targetFactionIds: Vector[FactionId]): Boolean =
targetFactionIds.isEmpty || targetFactionIds.contains(factionId)
val tutorialDialogues =
openingTutorialDialogue(actionResult.actionResultType, result.resultingState, Some(factionId))
if gsDiff.isDefined &&
actionResult.actingFactionId.isDefined &&
!actionResult.actingFactionId.exists(targetFid =>
@@ -220,11 +227,31 @@ object ActionResultFilter {
provinceId = actionResult.provinceId,
actingHeroId = actionResult.actingHeroId,
gameStateDiff = gsDiff,
notifications = filteredNotifications
notifications = filteredNotifications,
tutorialDialogues = tutorialDialogues
)
}
}
private def openingTutorialDialogue(
actionResultType: ActionResultType,
state: GameState,
factionId: Option[FactionId]
): Vector[TutorialDialogue] =
Option
.when(
factionId.contains(TutorialDialogueContent.playerFactionId) &&
actionResultType == ActionResultType.GameStart &&
state.gameType == GameType.Tutorial(TutorialPhase.OpeningBattle)
) {
val speakerHeroId = state.heroes.values
.find(_.nameTextId == LoadedHero.nameTextId("Old Marek the Learned"))
.map(_.id)
.getOrElse(throw new IllegalStateException("Old Marek is missing from the opening tutorial state"))
TutorialDialogueContent.opening(speakerHeroId)
}
.toVector
def filterForObserver(
startingState: GameState,
results: Vector[ActionResultWithResultingState]
@@ -115,6 +115,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/view/action_result:action_result_view",
"//src/main/scala/net/eagle0/eagle/model/view/game_state:game_state_view_diff",
"//src/main/scala/net/eagle0/util/hero_generation:loaded_hero",
],
)
@@ -1091,7 +1091,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:prime_stat_min_for_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_agility_for_engineer_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_agility_for_ranger_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_charisma_for_necromancer_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_charisma_for_paladin_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_constitution_for_warden_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_strength_for_champion_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_wisdom_for_mage_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:profession_gain_chance",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -8,7 +8,13 @@ import net.eagle0.eagle.library.actions.impl.action.HeroStatGainAction.*
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.settings.{
ExtraXpForStatBumpOver100,
PrimeStatMinForProfession,
MinAgilityForEngineerProfession,
MinAgilityForRangerProfession,
MinCharismaForNecromancerProfession,
MinCharismaForPaladinProfession,
MinConstitutionForWardenProfession,
MinStrengthForChampionProfession,
MinWisdomForMageProfession,
ProfessionGainChance,
XpForStatBump
}
@@ -28,13 +34,6 @@ object HeroStatGainAction {
private case object Charisma extends StatType
private case object Constitution extends StatType
private def professionsForStat(stat: StatType): Vector[Profession] = stat match {
case Strength => Vector(Profession.Champion)
case Agility => Vector(Profession.Engineer, Profession.Ranger)
case Wisdom => Vector(Profession.Mage)
case Charisma => Vector(Profession.Necromancer, Profession.Paladin)
case Constitution => Vector(Profession.Warden)
}
}
/**
@@ -44,8 +43,8 @@ object HeroStatGainAction {
* 1. Checks if any stat has accumulated enough XP to increase
* 2. For each stat that increases, calculates the delta and XP remainder (iteratively, since XP threshold increases
* for stats > 99)
* 3. For heroes without a profession, rolls for profession gain once per stat that gained, considering ALL stats >=
* 85
* 3. For heroes without a profession, rolls for profession gain once per stat that gained, considering all stats that
* meet their profession-specific requirements
*
* This replaces the automatic stat bump logic that was previously in ActionResultProtoApplier.
*/
@@ -162,33 +161,42 @@ case class HeroStatGainAction(
* profession. For example, with 10% chance and 3 stat gains, the probability of gaining a profession is: 1 - (0.9)^3
* 27.1%.
*
* The qualifying professions are determined by ALL stats >= 85 (after gains), not just the stats that increased.
* The qualifying professions are determined by all stats that meet their profession-specific requirements after
* gains, not just the stats that increased.
*/
private def checkForProfessionGain(
hero: HeroT,
statGains: Vector[StatGain],
random: FunctionalRandom
): RandomState[Option[Profession]] = {
val primeStatMin = PrimeStatMinForProfession.intValue
val numRolls = statGains.size // One roll per stat that gained
val numRolls = statGains.size // One roll per stat that gained
// Calculate all stats after applying gains
val statGainsByType = statGains.map(g => g.statType -> g.statDelta).toMap
def newStatValue(statType: StatType, currentValue: Int): Int =
currentValue + statGainsByType.getOrElse(statType, 0)
// Find all professions for which the hero qualifies (stat >= 85 after gains)
val qualifyingProfessions: Vector[Profession] = Vector(
(Strength, hero.strength),
(Agility, hero.agility),
(Wisdom, hero.wisdom),
(Charisma, hero.charisma),
(Constitution, hero.constitution)
).flatMap {
case (statType, currentValue) =>
if newStatValue(statType, currentValue) >= primeStatMin then professionsForStat(statType)
else Vector.empty
}
Option.when(newStatValue(Strength, hero.strength) >= MinStrengthForChampionProfession.intValue)(
Profession.Champion
),
Option.when(newStatValue(Agility, hero.agility) >= MinAgilityForEngineerProfession.intValue)(
Profession.Engineer
),
Option.when(newStatValue(Agility, hero.agility) >= MinAgilityForRangerProfession.intValue)(
Profession.Ranger
),
Option.when(newStatValue(Wisdom, hero.wisdom) >= MinWisdomForMageProfession.intValue)(Profession.Mage),
Option.when(newStatValue(Charisma, hero.charisma) >= MinCharismaForNecromancerProfession.intValue)(
Profession.Necromancer
),
Option.when(newStatValue(Charisma, hero.charisma) >= MinCharismaForPaladinProfession.intValue)(
Profession.Paladin
),
Option.when(newStatValue(Constitution, hero.constitution) >= MinConstitutionForWardenProfession.intValue)(
Profession.Warden
)
).flatten
if qualifyingProfessions.isEmpty then RandomState(None, random)
else {
@@ -3617,8 +3617,8 @@ scala_setting_library(
)
scala_setting_library(
name = "prime_stat_min_for_profession",
setting_name = "PrimeStatMinForProfession",
name = "min_strength_for_champion_profession",
setting_name = "MinStrengthForChampionProfession",
setting_type = "Int",
setting_value = "85",
visibility = [
@@ -3629,6 +3629,84 @@ scala_setting_library(
],
)
scala_setting_library(
name = "min_agility_for_engineer_profession",
setting_name = "MinAgilityForEngineerProfession",
setting_type = "Int",
setting_value = "85",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "min_wisdom_for_mage_profession",
setting_name = "MinWisdomForMageProfession",
setting_type = "Int",
setting_value = "85",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "min_charisma_for_necromancer_profession",
setting_name = "MinCharismaForNecromancerProfession",
setting_type = "Int",
setting_value = "85",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "min_charisma_for_paladin_profession",
setting_name = "MinCharismaForPaladinProfession",
setting_type = "Int",
setting_value = "85",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "min_agility_for_ranger_profession",
setting_name = "MinAgilityForRangerProfession",
setting_type = "Int",
setting_value = "85",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "min_constitution_for_warden_profession",
setting_name = "MinConstitutionForWardenProfession",
setting_type = "Int",
setting_value = "94",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "profession_gain_chance",
setting_name = "ProfessionGainChance",
@@ -177,7 +177,12 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:max_supported_players",
"//src/main/scala/net/eagle0/eagle/library/settings:maximum_faction_leaders",
"//src/main/scala/net/eagle0/eagle/library/settings:maximum_price_index",
"//src/main/scala/net/eagle0/eagle/library/settings:min_agility_for_engineer_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_agility_for_ranger_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_chance_for_a_i_invite",
"//src/main/scala/net/eagle0/eagle/library/settings:min_charisma_for_necromancer_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_charisma_for_paladin_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_constitution_for_warden_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_desired_alms_to_province_given",
"//src/main/scala/net/eagle0/eagle/library/settings:min_desired_gold_in_province_given",
"//src/main/scala/net/eagle0/eagle/library/settings:min_desired_improve_amount_in_quest",
@@ -191,6 +196,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:min_odds_for_quest",
"//src/main/scala/net/eagle0/eagle/library/settings:min_odds_for_recruitment",
"//src/main/scala/net/eagle0/eagle/library/settings:min_riot_size",
"//src/main/scala/net/eagle0/eagle/library/settings:min_strength_for_champion_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_alms",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_apprehend_outlaw",
@@ -209,6 +215,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_suppress_beasts",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_train",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_travel",
"//src/main/scala/net/eagle0/eagle/library/settings:min_wisdom_for_mage_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_agility_for_archery",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_agility_for_start_fire",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_loyalty_for_swear_brotherhood",
@@ -239,7 +246,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:prestige_per_supported_province",
"//src/main/scala/net/eagle0/eagle/library/settings:price_index_return_rate",
"//src/main/scala/net/eagle0/eagle/library/settings:price_index_shift_per_gold",
"//src/main/scala/net/eagle0/eagle/library/settings:prime_stat_min_for_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:prisoner_escape_chance",
"//src/main/scala/net/eagle0/eagle/library/settings:prisoner_escape_on_move_chance",
"//src/main/scala/net/eagle0/eagle/library/settings:profession_gain_chance",
@@ -5,6 +5,12 @@ import net.eagle0.eagle.model.proto_converters.view.game_state.GameStateViewDiff
import net.eagle0.eagle.model.proto_converters.NotificationConverter
import net.eagle0.eagle.model.view.action_result.ActionResultView
import net.eagle0.eagle.views.action_result_view.ActionResultView as ActionResultViewProto
import net.eagle0.eagle.views.tutorial_dialogue.{
TutorialDialogue as TutorialDialogueProto,
TutorialDialogueStep as TutorialDialogueStepProto,
TutorialFocus as TutorialFocusProto,
TutorialHexTile as TutorialHexTileProto
}
object ActionResultViewConverter {
def toProto(view: ActionResultView): ActionResultViewProto =
@@ -16,6 +22,30 @@ object ActionResultViewConverter {
gameStateDiff = view.gameStateDiff.map(GameStateViewDiffConverter.toProto),
notifications = view.notifications
.map(NotificationConverter.toProto)
.collect { case (proto, false) => proto }
.collect { case (proto, false) => proto },
tutorialDialogues = view.tutorialDialogues.map(dialogue =>
TutorialDialogueProto(
id = dialogue.id,
steps = dialogue.steps.map(step =>
TutorialDialogueStepProto(
speakerHeroId = step.speakerHeroId,
dialogueTextId = step.dialogueTextId,
instructionTextId = step.instructionTextId,
focus = step.focus.map {
case net.eagle0.eagle.model.view.action_result.TutorialFocus.Province(provinceId) =>
TutorialFocusProto(target = TutorialFocusProto.Target.ProvinceId(provinceId))
case net.eagle0.eagle.model.view.action_result.TutorialFocus.Button(buttonId) =>
TutorialFocusProto(target = TutorialFocusProto.Target.ButtonId(buttonId))
case net.eagle0.eagle.model.view.action_result.TutorialFocus.HexTile(row, column) =>
TutorialFocusProto(
target = TutorialFocusProto.Target.HexTile(TutorialHexTileProto(row = row, column = column))
)
case net.eagle0.eagle.model.view.action_result.TutorialFocus.Unit(unitId) =>
TutorialFocusProto(target = TutorialFocusProto.Target.UnitId(unitId))
}
)
)
)
)
)
}
@@ -9,6 +9,7 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:tutorial_dialogue_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
@@ -11,5 +11,6 @@ case class ActionResultView(
provinceId: Option[ProvinceId] = None,
actingHeroId: Option[HeroId] = None,
gameStateDiff: Option[GameStateViewDiff] = None,
notifications: Vector[NotificationT] = Vector.empty
notifications: Vector[NotificationT] = Vector.empty,
tutorialDialogues: Vector[TutorialDialogue] = Vector.empty
)
@@ -2,10 +2,15 @@ load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "action_result_view",
srcs = ["ActionResultView.scala"],
srcs = [
"ActionResultView.scala",
"TutorialDialogue.scala",
"TutorialDialogueContent.scala",
],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/action_result:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/controller:__pkg__",
],
deps = [
@@ -0,0 +1,18 @@
package net.eagle0.eagle.model.view.action_result
sealed trait TutorialFocus
object TutorialFocus {
case class Province(provinceId: Int) extends TutorialFocus
case class Button(buttonId: String) extends TutorialFocus
case class HexTile(row: Int, column: Int) extends TutorialFocus
case class Unit(unitId: Int) extends TutorialFocus
}
case class TutorialDialogueStep(
speakerHeroId: Int,
dialogueTextId: String,
instructionTextId: Option[String] = None,
focus: Option[TutorialFocus] = None
)
case class TutorialDialogue(id: String, steps: Vector[TutorialDialogueStep])
@@ -0,0 +1,38 @@
package net.eagle0.eagle.model.view.action_result
object TutorialDialogueContent {
val playerFactionId: Int = 3
val openingFirstDialogueTextId: String = "tutorial_opening_dialogue_1"
val openingSecondDialogueTextId: String = "tutorial_opening_dialogue_2"
val openingThirdDialogueTextId: String = "tutorial_opening_dialogue_3"
val openingInstructionTextId: String = "tutorial_opening_instruction"
val textIds: Vector[String] = Vector(
openingFirstDialogueTextId,
openingSecondDialogueTextId,
openingThirdDialogueTextId,
openingInstructionTextId
)
def opening(speakerHeroId: Int): TutorialDialogue = TutorialDialogue(
id = "tutorial_opening",
steps = Vector(
TutorialDialogueStep(
speakerHeroId = speakerHeroId,
dialogueTextId = openingFirstDialogueTextId
),
TutorialDialogueStep(
speakerHeroId = speakerHeroId,
dialogueTextId = openingSecondDialogueTextId,
focus = Some(TutorialFocus.Province(provinceId = 14))
),
TutorialDialogueStep(
speakerHeroId = speakerHeroId,
dialogueTextId = openingThirdDialogueTextId,
instructionTextId = Some(openingInstructionTextId),
focus = Some(TutorialFocus.Button(buttonId = "FightButton"))
)
)
)
}
@@ -289,6 +289,7 @@ scala_library(
srcs = ["GamesManager.scala"],
resources = [
"//src/main/resources/net/eagle0/eagle:settings",
"//src/main/resources/net/eagle0/eagle:tutorial_dialogue_text",
"//src/main/resources/net/eagle0/shardok:battalion_types",
],
visibility = [
@@ -335,6 +336,7 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:proto_parser",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/common:tsv_utils",
"//src/main/scala/net/eagle0/common:zip_utils",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
@@ -367,6 +369,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/shardok_battle",
"//src/main/scala/net/eagle0/eagle/model/view/action_result:action_result_view",
"//src/main/scala/net/eagle0/eagle/service/controller:game_controller",
"//src/main/scala/net/eagle0/eagle/service/controller:human_player_client_connection_state",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation",
@@ -6,7 +6,7 @@ import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Random, Success}
import io.sentry.Sentry
import net.eagle0.common.{ProtoParser, SeededRandom, SimpleTimedLogger, ZipUtils}
import net.eagle0.common.{ProtoParser, SeededRandom, SimpleTimedLogger, TsvUtils, ZipUtils}
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc.ShardokInternalInterfaceStub
import net.eagle0.common.tutorial_battle_config.TutorialBattleConfig
import net.eagle0.eagle.{ClientTextId, FactionId, GameId, ProvinceId, ShardokGameId, UserId}
@@ -47,6 +47,7 @@ import net.eagle0.eagle.model.state.{BattalionType, TutorialPhase}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.shardok_battle.ShardokBattle
import net.eagle0.eagle.model.view.action_result.TutorialDialogueContent
import net.eagle0.eagle.service.controller.*
import net.eagle0.eagle.service.new_game_creation.{
ExpandedGameParameters,
@@ -120,11 +121,19 @@ object GamesManager {
private def isOpeningTutorialBattle(battle: ShardokBattle): Boolean =
battle.battleIndex == 1 && battle.reinforcementHeroIds.nonEmpty
private val tutorialDialogueTexts: Map[ClientTextId, String] =
TsvUtils
.loadMaps(getClass.getResource("/net/eagle0/eagle/tutorial_dialogue_text.tsv"))
.getOrElse(throw new EagleInternalException("Failed to load tutorial dialogue text"))
.map(row => row("text_id") -> row("text").replace("\\n", "\n"))
.toMap
private val pregeneratedTexts = PregeneratedClientTextStore(
texts = GameParametersUtils.fixedHeroesNameMap ++
GameParametersUtils.pregeneratedHeroesNameMap ++
GameParametersUtils.fixedHeroBackstories ++
GameParametersUtils.pregeneratedHeroBackstories +
GameParametersUtils.pregeneratedHeroBackstories ++
tutorialDialogueTexts +
(NewGameCreation.initialChronicleGeneratedTextId -> NewGameCreation.initialChronicleText) +
(TutorialGameCreation.tutorialChronicleGeneratedTextId -> TutorialGameCreation.tutorialChronicleText)
)
@@ -190,6 +199,19 @@ object GamesManager {
factionIds
)
private def withPopulatedTutorialTexts(
clientTextStore: ClientTextStore,
factionIds: Vector[FactionId]
): ClientTextStore =
clientTextStore.withAddedCompleteTexts(
TutorialDialogueContent.textIds.map(textId =>
textId -> pregeneratedTexts
.getText(textId)
.getOrElse(throw new EagleInternalException(s"Missing pregenerated tutorial dialogue text: $textId"))
),
factionIds
)
private[service] def pregeneratedTextPairs(
pregenerated: PregeneratedClientTextStore,
heroes: Iterable[HeroT]
@@ -1952,10 +1974,13 @@ class GamesManager(
)
// Copy pregenerated texts (hero names, backstories) into the game's text store
val populatedTextStore = GamesManager.withPopulatedPregeneratedTexts(
clientTextStore = initialTextStore,
heroes = engineAndResults.engine.currentState.heroes.values,
factionIds = engineAndResults.engine.factionIds
val populatedTextStore = GamesManager.withPopulatedTutorialTexts(
clientTextStore = GamesManager.withPopulatedPregeneratedTexts(
clientTextStore = initialTextStore,
heroes = engineAndResults.engine.currentState.heroes.values,
factionIds = engineAndResults.engine.factionIds
),
factionIds = Vector(TutorialDialogueContent.playerFactionId)
)
// Player is Sadar Rakon (faction 3), AI factions are King (2), Bridget (4), and Hedrick (5)
@@ -18,18 +18,22 @@ object InMemoryHistory {
val applier = ActionResultApplierImpl(validator = Some(RuntimeValidator))
/** Creates an InMemoryHistory from Scala ActionResultT objects (no proto conversion needed) */
def fromScalaResults(results: Vector[ActionResultT]): InMemoryHistory =
def fromScalaResults(
results: Vector[ActionResultT],
startingState: GameState = emptyGameState
): InMemoryHistory =
InMemoryHistory(
history = results
.foldLeft(Vector.empty[ActionResultWithResultingState]) { (awrss, result) =>
val state = awrss.lastOption
.map(_.resultingState)
.getOrElse(emptyGameState)
.getOrElse(startingState)
val awrs = applier.applyActionResult(state, result)
awrss :+ awrs
}
},
startingState = startingState
)
private val emptyGameState: GameState = GameState(
@@ -64,7 +68,10 @@ object InMemoryHistory {
)
}
final case class InMemoryHistory(history: Vector[ActionResultWithResultingState]) extends FullGameHistory {
final case class InMemoryHistory(
history: Vector[ActionResultWithResultingState],
startingState: GameState = InMemoryHistory.emptyGameState
) extends FullGameHistory {
override def all: Vector[ActionResultWithResultingState] = history
@@ -84,13 +91,17 @@ final case class InMemoryHistory(history: Vector[ActionResultWithResultingState]
override def withNewResults(
newResults: Vector[ActionResultWithResultingState]
): InMemoryHistory =
InMemoryHistory(history ++ newResults)
copy(history = history ++ newResults)
override def truncateTo(targetActionCount: Int): InMemoryHistory =
InMemoryHistory(history.take(targetActionCount))
copy(history = history.take(targetActionCount))
override def stateAfter(count: Int): GameState =
history(count).resultingState
override def stateAfter(count: Int): GameState = {
if count < 0 || count > history.length then
throw new EagleInternalException(s"stateAfter($count) out of range; count is ${history.length}")
if count == 0 then startingState else history(count - 1).resultingState
}
override def shardokCount(shardokGameId: ShardokGameId): Int = 0
@@ -77,7 +77,7 @@ class PostgresHistory private[service] (
dbFile: java.io.File,
startingState: GameState,
finalState: GameState
): SqliteHistory = {
): SqliteHistory = withDbLock {
val sqlite = SqliteHistory.createForSaveExport(dbFile, startingState)
sqlite.importSaveExportRowsFromJdbc(connection, finalState)
sqlite
@@ -13,6 +13,7 @@ import io.grpc.{ManagedChannel, ManagedChannelBuilder, Metadata}
import io.grpc.stub.{MetadataUtils, StreamObserver}
import net.eagle0.eagle.api.available_command.AvailableCommand as AvailableCommandProto
import net.eagle0.eagle.api.available_command.MarchAvailableCommand
import net.eagle0.eagle.api.available_command as ac
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.eagle.*
import net.eagle0.eagle.api.eagle.EagleGrpc.EagleStub
@@ -28,7 +29,7 @@ import net.eagle0.eagle.views.province_view.{FullProvinceInfo, FullProvinceInfoD
import net.eagle0.shardok.api.command_descriptor.{AvailableCommands as ShardokAvailableCommands, CommandDescriptor}
import net.eagle0.shardok.api.unit_view.UnitView
import scalapb.{GeneratedMessage, GeneratedMessageCompanion}
import scalapb.json4s.JsonFormat
import scalapb.json4s.{JsonFormat, Printer}
object EagleTextClient {
private val AuthorizationKey: Metadata.Key[String] =
@@ -531,14 +532,21 @@ final class ClientState {
println(s"post-command: ${value.status} game=${value.gameId} ${value.errorMessage}")
case UpdateStreamResponse.ResponseDetails.GameUpdate(value) =>
latestGameId = Some(value.gameId)
value.startingState.foreach(state => latestState = Some(state))
value.startingState.foreach { state =>
latestState = Some(state)
latestShardokBattles = Map.empty
}
if value.serverGameStatus.isDefined then latestStatus = value.serverGameStatus
value.gameUpdateDetails match {
case GameUpdate.GameUpdateDetails.ActionResultResponse(ar) =>
latestUnfilteredResultCount = ar.unfilteredResultCountAfter
latestState = latestState.map(state =>
ar.actionResultViews.foldLeft(state) { (current, actionResult) =>
actionResult.gameStateDiff.map(applyGameStateDiff(current, _)).getOrElse(current)
actionResult.gameStateDiff.map { diff =>
latestShardokBattles --= diff.removedBattleIds
applyGameStateDiff(current, diff)
}
.getOrElse(current)
}
)
if ar.availableCommands.isDefined then latestCommands = ar.availableCommands
@@ -607,7 +615,7 @@ final class ClientState {
val updated = ShardokBattleState(
shardokGameId = response.shardokGameId,
token = response.newResultViewCount,
availableCommands = response.availableCommands.orElse(existing.flatMap(_.availableCommands)),
availableCommands = response.availableCommands,
status = response.shardokServerStatus.orElse(existing.flatMap(_.status)),
units = snapshot.units,
reserveUnits = snapshot.reserveUnits,
@@ -914,9 +922,17 @@ final class ClientState {
.flatMap(_.commands.collectFirst { case command: MarchAvailableCommand => command })
def shardokCommandPost(shardokGameId: Option[String], index: Int): Option[ShardokCommandPost] = synchronized {
val candidates = shardokGameId match {
case Some(id) => latestShardokBattles.get(id).toVector
case None => latestShardokBattles.values.toVector
val outstandingBattleIds = latestState.toVector
.flatMap(_.outstandingBattles)
.flatMap(_.shardokGameId)
.toSet
val activeCandidates = latestShardokBattles.values.toVector
.filter(battle => outstandingBattleIds.contains(battle.shardokGameId))
val candidates = shardokGameId match {
case Some(id) => activeCandidates.filter(_.shardokGameId == id)
case None =>
val postable = activeCandidates.filter(_.availableCommands.exists(_.currentCommand.nonEmpty))
Option.when(postable.size == 1)(postable.head).toVector
}
candidates
@@ -1015,7 +1031,11 @@ final class ClientState {
val commandLines = oneProvince.commands.zipWithIndex.map {
case (cmd, index) =>
val prefix = s" [$index] ${commandName(cmd)}"
if rawJson then s"$prefix ${commandJson(cmd)}" else prefix
if rawJson then
val (selectedType, json) = selectedCommandTemplate(cmd)
val template = s"\n post-json $provinceId $selectedType $json"
s"$prefix ${commandJson(cmd)}$template"
else prefix
}
s"province $provinceId suggested=${oneProvince.suggestedCommandIndex}" +: commandLines
}
@@ -1099,6 +1119,32 @@ final class ClientState {
private def toJson(message: GeneratedMessage): String =
JsonFormat.toJsonString(message)
private def selectedCommandTemplate(command: AvailableCommandProto): (String, String) = {
val selectedType = commandName(command).replace("AvailableCommand", "SelectedCommand")
val selected = command match {
case ransom: ac.ResolveRansomOfferAvailableCommand =>
ransom.offers.headOption
.map(offer =>
sc.ResolveRansomOfferSelectedCommand(
offeringFactionId = offer.originatingFactionId,
ransomOffer = Some(offer),
resolution = offer.status
)
)
.getOrElse(selectedCommandDefault(selectedType))
case _ => selectedCommandDefault(selectedType)
}
selectedType -> Printer().includingDefaultValueFields.print(selected)
}
private def selectedCommandDefault(selectedType: String): GeneratedMessage =
Try {
val companionClass = Class.forName(s"net.eagle0.eagle.api.selected_command.$selectedType$$")
val companion = companionClass.getField("MODULE$").get(null).asInstanceOf[GeneratedMessageCompanion[?]]
companion.defaultInstance.asInstanceOf[GeneratedMessage]
}.getOrElse(throw new IllegalArgumentException(s"No selected-command type found for $selectedType"))
private def shardokCommandSummary(command: CommandDescriptor, rawJson: Boolean): String = {
val actor = command.actor.map(_.toString).getOrElse("-")
val target = command.target.map(c => s"(${c.row},${c.column})").getOrElse("-")
@@ -1034,7 +1034,13 @@ scala_test(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:hero_stat_gain_action",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:prime_stat_min_for_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_agility_for_engineer_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_agility_for_ranger_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_charisma_for_necromancer_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_charisma_for_paladin_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_constitution_for_warden_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_strength_for_champion_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:min_wisdom_for_mage_profession",
"//src/main/scala/net/eagle0/eagle/library/settings:profession_gain_chance",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -3,7 +3,13 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, SeededRandom}
import net.eagle0.eagle.library.settings.{
ExtraXpForStatBumpOver100,
PrimeStatMinForProfession,
MinAgilityForEngineerProfession,
MinAgilityForRangerProfession,
MinCharismaForNecromancerProfession,
MinCharismaForPaladinProfession,
MinConstitutionForWardenProfession,
MinStrengthForChampionProfession,
MinWisdomForMageProfession,
ProfessionGainChance,
XpForStatBump
}
@@ -30,7 +36,13 @@ class HeroStatGainActionTest extends AnyFlatSpec with Matchers with BeforeAndAft
override def beforeEach(): Unit = {
XpForStatBump.setIntValue(100)
ExtraXpForStatBumpOver100.setIntValue(5)
PrimeStatMinForProfession.setIntValue(85)
MinStrengthForChampionProfession.setIntValue(85)
MinAgilityForEngineerProfession.setIntValue(85)
MinWisdomForMageProfession.setIntValue(85)
MinCharismaForNecromancerProfession.setIntValue(85)
MinCharismaForPaladinProfession.setIntValue(85)
MinAgilityForRangerProfession.setIntValue(85)
MinConstitutionForWardenProfession.setIntValue(94)
ProfessionGainChance.setDoubleValue(0.1) // 10% chance
}
@@ -253,13 +265,13 @@ class HeroStatGainActionTest extends AnyFlatSpec with Matchers with BeforeAndAft
}
}
it should "gain Warden when constitution crosses threshold" in {
it should "gain Warden when constitution reaches its separate threshold" in {
ProfessionGainChance.setDoubleValue(1.0) // Always succeed
val hero = HeroC(
id = 1,
constitution = 84,
constitutionXp = 100 // Will bump to 85
constitution = 93,
constitutionXp = 100 // Will bump to 94
)
val results = executeAction(Vector(hero))
@@ -27,6 +27,7 @@ import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.backstory_version.BackstoryVersion
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.view.action_result.TutorialDialogueContent
import net.eagle0.eagle.service.controller.GameController
import net.eagle0.eagle.service.controller.HumanPlayerClientConnectionState
import net.eagle0.eagle.service.new_game_creation.{
@@ -1179,6 +1180,16 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val factionIds = controller.engine.currentState.factions.keys.toVector
val chronicleId = "chronicle_update_371_11"
TutorialDialogueContent.textIds.foreach { textId =>
controller.clientTextStore.completeTexts.keySet should contain(textId)
}
controller.clientTextStore
.getCompleteTextsAccessibleTo(TutorialDialogueContent.playerFactionId)
.map(_.id) should contain allElementsOf TutorialDialogueContent.textIds
controller.clientTextStore.completeTexts(TutorialDialogueContent.openingFirstDialogueTextId).text should include(
"\n\n"
)
// A yearly chronicle text generated during the live tutorial session, then persisted.
val _ = controller.clientTextStore
.withAddedCompleteText(chronicleId, "A Realm in Ruin", factionIds)
@@ -30,26 +30,27 @@ class InMemoryHistoryTest extends AnyFlatSpec with Matchers {
"truncateTo" should "return the same history when target equals count" in {
val truncated = history.truncateTo(5)
truncated.count shouldBe 5
truncated.stateAfter(4).randomSeed shouldBe 5L
truncated.stateAfter(5).randomSeed shouldBe 5L
}
it should "truncate to the correct number of actions" in {
val truncated = history.truncateTo(3)
truncated.count shouldBe 3
truncated.stateAfter(2).randomSeed shouldBe 3L
truncated.stateAfter(3).randomSeed shouldBe 3L
}
it should "handle truncating to 1 action" in {
val truncated = history.truncateTo(1)
truncated.count shouldBe 1
truncated.stateAfter(0).randomSeed shouldBe 1L
truncated.stateAfter(1).randomSeed shouldBe 1L
}
it should "preserve all states correctly after truncation" in {
val truncated = history.truncateTo(3)
truncated.stateAfter(0).randomSeed shouldBe 1L
truncated.stateAfter(1).randomSeed shouldBe 2L
truncated.stateAfter(2).randomSeed shouldBe 3L
truncated.stateAfter(0).randomSeed shouldBe 0L
truncated.stateAfter(1).randomSeed shouldBe 1L
truncated.stateAfter(2).randomSeed shouldBe 2L
truncated.stateAfter(3).randomSeed shouldBe 3L
}
it should "work multiple times in sequence" in {
@@ -57,16 +58,17 @@ class InMemoryHistoryTest extends AnyFlatSpec with Matchers {
truncated1.count shouldBe 4
val truncated2 = truncated1.truncateTo(2)
truncated2.count shouldBe 2
truncated2.stateAfter(1).randomSeed shouldBe 2L
truncated2.stateAfter(2).randomSeed shouldBe 2L
}
"count" should "return the correct number of actions" in {
history.count shouldBe 5
}
"stateAfter" should "return the correct state at each index" in {
history.stateAfter(0).randomSeed shouldBe 1L
history.stateAfter(2).randomSeed shouldBe 3L
history.stateAfter(4).randomSeed shouldBe 5L
"stateAfter" should "return the state after exactly the requested action count" in {
history.stateAfter(0).randomSeed shouldBe 0L
history.stateAfter(1).randomSeed shouldBe 1L
history.stateAfter(3).randomSeed shouldBe 3L
history.stateAfter(5).randomSeed shouldBe 5L
}
}
@@ -7,6 +7,7 @@ import scala.util.{Failure, Try}
import net.eagle0.eagle.library.settings.loaders.BattalionTypeLoader
import net.eagle0.eagle.library.settings.HistoryBackend
import net.eagle0.eagle.model.view.action_result.{TutorialDialogueContent, TutorialFocus}
import net.eagle0.eagle.service.persistence.Persister
import net.eagle0.util.hero_generation.LoadedHero
import org.scalatest.flatspec.AnyFlatSpec
@@ -174,4 +175,28 @@ class TutorialGameCreationTest extends AnyFlatSpec with Matchers {
gameState.heroes(100).nameTextId.shouldBe(LoadedHero.nameTextId("John Ranil"))
gameState.heroes(101).nameTextId.shouldBe(LoadedHero.nameTextId("Elena Fyar"))
}
it should "deliver the opening dialogue in the player's game-start result" in {
val history = engineAndResults.engine.filteredHistoryWithUnfilteredCount(
factionId = TutorialDialogueContent.playerFactionId,
knownUnfilteredCount = 0
)
val dialogues = history.filteredResults.flatMap(_.tutorialDialogues)
dialogues.map(_.id).should(contain("tutorial_opening"))
inside(dialogues.find(_.id == "tutorial_opening")) {
case Some(opening) =>
opening.steps.should(have.size(3))
engineAndResults.engine.currentState.heroes(opening.steps.head.speakerHeroId).nameTextId shouldBe
LoadedHero.nameTextId("Old Marek the Learned")
opening.steps.map(_.dialogueTextId) shouldBe Vector(
TutorialDialogueContent.openingFirstDialogueTextId,
TutorialDialogueContent.openingSecondDialogueTextId,
TutorialDialogueContent.openingThirdDialogueTextId
)
opening.steps(1).focus.shouldBe(Some(TutorialFocus.Province(provinceId = 14)))
opening.steps(2).focus.shouldBe(Some(TutorialFocus.Button(buttonId = "FightButton")))
opening.steps.last.instructionTextId.shouldBe(Some(TutorialDialogueContent.openingInstructionTextId))
}
}
}
@@ -1,13 +1,83 @@
package net.eagle0.eagle.text_client
import net.eagle0.eagle.api.eagle.{ActionResultResponse, GameUpdate, UpdateStreamResponse}
import net.eagle0.eagle.api.available_command.{ImproveAvailableCommand, ResolveRansomOfferAvailableCommand}
import net.eagle0.eagle.api.command.{AvailableCommands as EagleAvailableCommands, OneProvinceAvailableCommands}
import net.eagle0.eagle.api.eagle.{ActionResultResponse, GameUpdate, ShardokActionResultResponse, UpdateStreamResponse}
import net.eagle0.eagle.common.diplomacy_offer.DiplomacyOffer
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.views.game_state_view.{GameStateView, GameStateViewDiff}
import net.eagle0.eagle.views.province_view.{ProvinceView, ProvinceViewDiff}
import net.eagle0.eagle.views.shardok_battle_view.ShardokBattleView
import net.eagle0.shardok.api.command_descriptor.{AvailableCommands, CommandDescriptor}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class ClientStateTest extends AnyFlatSpec with Matchers {
private def startingState(battleIds: String*): UpdateStreamResponse =
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
startingState = Some(
GameStateView(
gameId = 1L,
outstandingBattles = battleIds.map(id => ShardokBattleView(shardokGameId = Some(id))).toVector
)
)
)
)
)
private def shardokUpdate(
battleId: String,
commands: Option[AvailableCommands]
): UpdateStreamResponse =
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
gameUpdateDetails = GameUpdate.GameUpdateDetails.ShardokActionResultResponse(
ShardokActionResultResponse(
shardokGameResponses = Vector(
ShardokActionResultResponse.SingleShardokGameResultResponse(
shardokGameId = battleId,
newResultViewCount = 1,
availableCommands = commands
)
)
)
)
)
)
)
private val oneShardokCommand = AvailableCommands(
currentCommand = Vector(CommandDescriptor(player = 3))
)
private def eagleCommandsUpdate(commands: Vector[net.eagle0.eagle.api.available_command.AvailableCommand]) =
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
gameUpdateDetails = GameUpdate.GameUpdateDetails.ActionResultResponse(
ActionResultResponse(
unfilteredResultCountAfter = 1,
availableCommands = Some(
EagleAvailableCommands(
token = 1L,
commandsByProvince = Map(
12 -> OneProvinceAvailableCommands(provinceId = 12, commands = commands)
)
)
)
)
)
)
)
)
"ClientTextStore" should "resume UTF-8 streaming text from byte offsets" in {
val store = new ClientTextStore
@@ -75,4 +145,96 @@ class ClientStateTest extends AnyFlatSpec with Matchers {
state.describeState should include("province 39: Kojaria ruler=3")
}
it should "clear Shardok commands when a later update omits them" in {
val state = new ClientState
state.handle(startingState("battle-1"))
state.handle(shardokUpdate("battle-1", Some(oneShardokCommand)))
state.shardokCommandPost(Some("battle-1"), index = 0).map(_.shardokGameId) shouldBe Some("battle-1")
state.handle(shardokUpdate("battle-1", commands = None))
state.shardokCommandPost(Some("battle-1"), index = 0) shouldBe None
}
it should "post implicitly only when exactly one outstanding battle has commands" in {
val state = new ClientState
state.handle(startingState("battle-1", "battle-2"))
state.handle(shardokUpdate("battle-1", Some(oneShardokCommand)))
state.handle(shardokUpdate("battle-2", commands = None))
state.shardokCommandPost(shardokGameId = None, index = 0).map(_.shardokGameId) shouldBe Some("battle-1")
state.handle(shardokUpdate("battle-2", Some(oneShardokCommand)))
state.shardokCommandPost(shardokGameId = None, index = 0) shouldBe None
state.shardokCommandPost(Some("battle-2"), index = 0).map(_.shardokGameId) shouldBe Some("battle-2")
}
it should "remove cached Shardok state when Eagle removes the battle" in {
val state = new ClientState
state.handle(startingState("battle-1"))
state.handle(shardokUpdate("battle-1", Some(oneShardokCommand)))
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
gameUpdateDetails = GameUpdate.GameUpdateDetails.ActionResultResponse(
ActionResultResponse(
unfilteredResultCountAfter = 1,
actionResultViews = Vector(
ActionResultView(
gameStateDiff = Some(GameStateViewDiff(removedBattleIds = Vector("battle-1")))
)
)
)
)
)
)
)
)
state.describeShardokCommands(rawJson = false) shouldBe "No Shardok battles yet."
state.shardokCommandPost(Some("battle-1"), index = 0) shouldBe None
}
it should "include selected command templates in JSON command output" in {
val state = new ClientState
state.handle(startingState())
state.handle(eagleCommandsUpdate(Vector(ImproveAvailableCommand(actingProvinceId = 12))))
val output = state.describeCommands(rawJson = true)
output should include("post-json 12 ImproveSelectedCommand")
output should include("\"actingHeroId\":0")
output should include("\"lockType\":false")
}
it should "copy a ransom offer into its selected command template" in {
val state = new ClientState
state.handle(startingState())
state.handle(
eagleCommandsUpdate(
Vector(
ResolveRansomOfferAvailableCommand(
offers = Vector(
DiplomacyOffer(
originatingFactionId = 2,
targetFactionId = 3,
status = DiplomacyOfferStatus.DIPLOMACY_OFFER_STATUS_UNRESOLVED
)
)
)
)
)
)
val output = state.describeCommands(rawJson = true)
output should include("post-json 12 ResolveRansomOfferSelectedCommand")
output should include("\"offeringFactionId\":2")
output should include("\"ransomOffer\":{")
output should include("\"originatingFactionId\":2")
}
}