mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 06:15:41 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116d8681e2 | ||
|
|
09237eb9b1 | ||
|
|
b15a9f0878 | ||
|
|
a478906805 | ||
|
|
9619cb8076 | ||
|
|
588bcbd81f | ||
|
|
16cacb9ee8 | ||
|
|
150970fcae | ||
|
|
de6ccb4345 | ||
|
|
ab64052fa7 | ||
|
|
38129c2a8e | ||
|
|
2fd16d962c | ||
|
|
4dcb4bacdb | ||
|
|
e1dd455af3 | ||
|
|
1cd7474864 | ||
|
|
c1c60c406a | ||
|
|
b20c8e55db | ||
|
|
ca485e2c05 |
@@ -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
|
||||
|
||||
@@ -23,8 +23,11 @@ export default defineConfig({
|
||||
{ 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",
|
||||
@@ -34,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.
|
||||
|
||||
|
||||
@@ -180,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.
|
||||
|
||||
@@ -10,9 +10,12 @@ These pages explain how Eagle0's major systems fit together. Use them when you w
|
||||
- [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.
|
||||
|
||||
@@ -181,7 +181,8 @@ 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**.
|
||||
@@ -190,7 +191,7 @@ Only heroes with **NoProfession** can gain a profession. At the end of a round,
|
||||
|
||||
### Progression example
|
||||
|
||||
A NoProfession hero earns Agility-related XP from strategic actions. During end-of-round progression, enough XP may increase Agility. The game then evaluates all post-increase stats against the configured prime-stat threshold. If Agility qualifies, Engineer and Ranger are eligible. The hero receives profession-gain rolls for stats that increased; the player does not directly choose between eligible professions.
|
||||
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.
|
||||
|
||||
@@ -198,7 +199,7 @@ If several stats increase together, each increase can create a roll and all post
|
||||
|
||||
- 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.
|
||||
- A qualifying stat makes a profession possible but does not guarantee a particular random result.
|
||||
- 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
|
||||
|
||||
@@ -21,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.
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+9
-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}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+14
-7
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e3a7cbe4e1f4e59a2a57ea40c9f5e29
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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",
|
||||
|
||||
+32
-24
@@ -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",
|
||||
|
||||
+31
-1
@@ -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])
|
||||
+38
@@ -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] =
|
||||
@@ -1030,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
|
||||
}
|
||||
@@ -1114,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",
|
||||
|
||||
+17
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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,6 +1,10 @@
|
||||
package net.eagle0.eagle.text_client
|
||||
|
||||
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}
|
||||
@@ -52,6 +56,28 @@ class ClientStateTest extends AnyFlatSpec with Matchers {
|
||||
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
|
||||
|
||||
@@ -174,4 +200,41 @@ class ClientStateTest extends AnyFlatSpec with Matchers {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user