Merge branch 'feat/m5-progression'
This commit is contained in:
@@ -272,3 +272,21 @@ vorbeigeht, testet den Spielablauf nicht.
|
|||||||
**Immer mitgebaut**, nicht hinter `cfg(debug_assertions)`. Das wäre ein `#[cfg]` an der Variante *und*
|
**Immer mitgebaut**, nicht hinter `cfg(debug_assertions)`. Das wäre ein `#[cfg]` an der Variante *und*
|
||||||
an jedem `match` darüber — für ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht später nach,
|
an jedem `match` darüber — für ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht später nach,
|
||||||
wenn es je einen Release-Build gibt.
|
wenn es je einen Release-Build gibt.
|
||||||
|
|
||||||
|
### Befund aus M5 — die Naht zwischen Screen-Zustand und Spielzustand
|
||||||
|
|
||||||
|
Ticket 11 trennt sauber: Ein Screen fasst `Game` nicht an, er gibt ein `Command` zurück. Der
|
||||||
|
Charakterbogen hält seit M5 aber selbst Zustand — die **vorgemerkten** Punkte, die erst mit `Enter`
|
||||||
|
abgeschickt werden. Beim Abschicken zieht das Spiel sie ab, der Screen behielt sie: `unspent_points`
|
||||||
|
stand auf 0, `pending` auf 1, und die Anzeige rechnete `0 - 1` auf `u32`. Absturz.
|
||||||
|
|
||||||
|
Das ist keine Schwäche der Trennung, sondern ihr Preis: Wer Zustand doppelt hält, muss sagen, wann er
|
||||||
|
abgeglichen wird. Die Antwort hier ist die einfachste mögliche — **vorgemerkt wird beim Abschicken
|
||||||
|
verbraucht**, der Screen leert sein Feld, während er das Kommando übergibt. Dazu `saturating_sub` an
|
||||||
|
den zwei Lesestellen, weil `unspent_points` sich auch unter dem offenen Screen ändern kann (ein
|
||||||
|
Aufstieg im selben Zug).
|
||||||
|
|
||||||
|
Dabei ist der **erste UI-Test** des Projekts entstanden. Bis M5 war `ui/` ungetestet, weil dort
|
||||||
|
angeblich nur Anzeige liegt — der Absturz zeigt, dass das nicht stimmt: Sobald ein Screen Zustand hält,
|
||||||
|
ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere. `handle_key` ist ohne Terminal
|
||||||
|
aufrufbar, der Test kostet zwanzig Zeilen.
|
||||||
|
|||||||
@@ -146,3 +146,34 @@ Blocker gewesen.
|
|||||||
Die Beute-Skalierung darf **nicht gegen fünf feste Etagen fest verdrahtet** werden — der Endlosmodus
|
Die Beute-Skalierung darf **nicht gegen fünf feste Etagen fest verdrahtet** werden — der Endlosmodus
|
||||||
kommt später, und eine Skalierung, die nur bis Etage 5 denkt, wäre dann ein Umbau statt einer
|
kommt später, und eine Skalierung, die nur bis Etage 5 denkt, wäre dann ein Umbau statt einer
|
||||||
Erweiterung.
|
Erweiterung.
|
||||||
|
|
||||||
|
### Befunde aus M5
|
||||||
|
|
||||||
|
**Zwei der fünf verteilbaren Grundwerte waren Fallen.** Das Ticket verteilt Punkte auf `strength`,
|
||||||
|
`dexterity`, `constitution`, `charisma` und `luck` — und begründet sauber, warum abgeleitete Größen
|
||||||
|
ausgeschlossen bleiben. Nur las `strength` und `constitution` bis M5 **niemand**: `damage` und
|
||||||
|
`max_health` sind eigene Attribute ohne Herleitung, `dexterity` steckt in Flucht und Initiative,
|
||||||
|
`charisma` seit M4 im Preis, `luck` jetzt in der Beute. Ein Punkt in Stärke hätte nichts getan.
|
||||||
|
|
||||||
|
Entschieden: `damage_per_strength` und `health_per_constitution` als `[balance]`-Knöpfe, angewandt
|
||||||
|
**beim Verteilen** und nicht beim Auslesen. Damit hebt ein Punkt in Konstitution den *Grundwert*
|
||||||
|
`max_health` — genau das, was der Satz „`max_health` steigt erst, wenn `constitution` tatsächlich
|
||||||
|
erhöht wird, also beim Verteilen" ohnehin voraussetzt. In `Game::attribute` gehört die Umrechnung
|
||||||
|
nicht: Dort wäre sie ein Sonderfall im generischen Pfad, dieselbe Begründung, mit der M4 die
|
||||||
|
Charisma-Formel in die Preisfunktion gelegt hat.
|
||||||
|
|
||||||
|
**Von den „zwei Stellen" ist nur eine echt.** Das Ticket nennt Aufstieg *und* `AssignPoints` als
|
||||||
|
Stellen, an denen der Lebensstand einem `max_health`-Zuwachs folgt. Im Code ist die erste beweisbar ein
|
||||||
|
No-op: Der Aufstieg hebt `base.level` und `unspent_points`, sonst nichts, und `per_level` hat dasselbe
|
||||||
|
Ticket ersatzlos gestrichen. Die vorsorgliche Vorher/Nachher-Klammer im Aufstieg ist raus — sie
|
||||||
|
suggerierte eine Regel, die es nicht gibt.
|
||||||
|
|
||||||
|
**`Rng::chance(0.0)` verbrauchte eine Zahl.** Aufgefallen, weil die Glücksregel aus Ticket 16 auch beim
|
||||||
|
Erzeugen einer Etage greift (Händlerregal, `luck = 0`) und damit den ganzen Zufallsstrom verschob — ein
|
||||||
|
bestehender Kampftest schlug fehl, weil sein Angriff plötzlich danebenging. `chance` kürzt jetzt bei
|
||||||
|
0 und 1 ab. Ohne das erzeugte derselbe Seed je nach Glück des Helden eine andere Etage, und die Zusage
|
||||||
|
aus Ticket 14 („Seed im Save ⇒ Nachladen würfelt nicht neu") wäre stillschweigend gebrochen gewesen.
|
||||||
|
|
||||||
|
**`Cheat::ClearRoom` ging am Todesweg vorbei.** Die Werkbank setzte den Lebensstand auf null und ließ
|
||||||
|
das Aufräumen am Zugende erledigen — ohne `check_death`, also ohne Todes-Trigger und ohne Erfahrung.
|
||||||
|
Genau der Fehler, gegen den ihr eigener Kommentar argumentierte. Sie ruft jetzt `check_death`.
|
||||||
|
|||||||
@@ -132,6 +132,35 @@ die Tickets zurückgetragen:
|
|||||||
Der Zugablauf aus **14** hat damit einen siebten Schritt, `Creature` ein Feld `intent`, und die
|
Der Zugablauf aus **14** hat damit einen siebten Schritt, `Creature` ein Feld `intent`, und die
|
||||||
Sperrgründe, die `ability_block` ohnehin berechnete, haben einen zweiten Nutzen bekommen.
|
Sperrgründe, die `ability_block` ohnehin berechnete, haben einen zweiten Nutzen bekommen.
|
||||||
|
|
||||||
|
**M5 — Progression** (`feat/m5-progression`, 65 Tests)
|
||||||
|
|
||||||
|
| Zusage | Aus | Befund |
|
||||||
|
|---|---|---|
|
||||||
|
| `current_exp`/`unspent_points` an `Game`, nicht am `Creature` | 15 | **bestätigt** — der Save schreibt sie nicht für jeden Gegner mit |
|
||||||
|
| `exp_reward` als Kurzform für einen Todes-Trigger | 15 | **bestätigt**, stand seit M3 ungelesen an vier Gegnern |
|
||||||
|
| Aufstieg in einer Schleife, drei Stufen = drei Punkte | 15 | **bestätigt** |
|
||||||
|
| Zwei Zahlen statt fünfzig Tabellenzeilen für die Kurve | 15 | **bestätigt** — `exp_base × level^exp_exponent` |
|
||||||
|
| `AssignPoints` als **ein** Kommando, **ein** Zug | 15 | **bestätigt** |
|
||||||
|
| Verteilen im Charakterbogen, kein zwölfter Screen | 11/15 | **bestätigt** |
|
||||||
|
| Punkte nur auf die fünf Grundwerte | 15 | **zwei davon waren Fallen** — siehe unten |
|
||||||
|
| Lebensstand folgt dem Zuwachs an **zwei** Stellen | 15 | **es ist eine** — der Aufstieg ändert `max_health` gar nicht |
|
||||||
|
| `luck` = eine Chance auf einen Zusatzzug, auf jeder Tabelle | 16 | **bestätigt**, wirkt dreifach aus einer Formel |
|
||||||
|
|
||||||
|
Ein fünfter kam beim Spielen dazu: **11** — der Charakterbogen hält seit M5 selbst Zustand (die
|
||||||
|
vorgemerkten Punkte). Beim Abschicken zog das Spiel sie ab, der Screen behielt sie, und die Anzeige
|
||||||
|
rechnete `0 - 1` auf `u32`. Daraus der **erste UI-Test** des Projekts: Sobald ein Screen Zustand hält,
|
||||||
|
ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere.
|
||||||
|
|
||||||
|
Vier Befunde nachgetragen: **15** (`strength` und `constitution` las niemand — zwei der fünf
|
||||||
|
Wahlmöglichkeiten taten nichts), **15** (von den zwei Zuwachs-Stellen ist eine beweisbar ein No-op),
|
||||||
|
**14/16** (`Rng::chance(0.0)` verbrauchte eine Zahl und verschob den Zufallsstrom — derselbe Seed
|
||||||
|
erzeugte je nach Glück des Helden eine andere Etage) und **11** (`Cheat::ClearRoom` ging am Todesweg
|
||||||
|
vorbei, also ohne Trigger und ohne Erfahrung).
|
||||||
|
|
||||||
|
Der dritte ist der interessanteste: Gefunden hat ihn ein **bestehender** Kampftest, dessen Angriff
|
||||||
|
plötzlich danebenging. Kein neuer Test, keine Überlegung — eine Zusage aus Ticket 14 wäre sonst
|
||||||
|
stillschweigend gebrochen gewesen.
|
||||||
|
|
||||||
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
|
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
|
||||||
|
|
||||||
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.
|
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.
|
||||||
|
|||||||
@@ -218,8 +218,25 @@ pub struct Balance {
|
|||||||
pub verkauf: f32,
|
pub verkauf: f32,
|
||||||
/// Wie stark `charisma` auf `price_modifier` wirkt (Ticket 02).
|
/// Wie stark `charisma` auf `price_modifier` wirkt (Ticket 02).
|
||||||
pub charisma_preis: f32,
|
pub charisma_preis: f32,
|
||||||
// TODO(M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor`
|
/// Die Erfahrungskurve: `exp_base × level^exp_exponent`. **Zwei Zahlen
|
||||||
// (Ticket 16) — sie kommen mit ihren Lesern, nicht davor.
|
/// statt fuenfzig Tabellenzeilen** (Ticket 15) — eine Tabelle liesse einen
|
||||||
|
/// Modder Level 13 zum Sprung machen, und das ist der Ausbau, nicht der
|
||||||
|
/// Anfang.
|
||||||
|
pub exp_base: f32,
|
||||||
|
pub exp_exponent: f32,
|
||||||
|
/// `p_extra = clamp(luck × luck_factor, 0, 0.9)` — eine Chance auf einen
|
||||||
|
/// zusaetzlichen Zug auf **jeder** Tabelle (Ticket 16).
|
||||||
|
pub luck_factor: f32,
|
||||||
|
/// Was ein Punkt in `strength` bzw. `constitution` tatsaechlich bewirkt.
|
||||||
|
///
|
||||||
|
/// **Befund aus M5:** Ticket 15 verteilt Punkte auf fuenf Grundwerte, aber
|
||||||
|
/// `strength` und `constitution` las bis dahin *niemand* — zwei der fuenf
|
||||||
|
/// Wahlmoeglichkeiten waren Fallen. Die Umrechnung sitzt hier und nicht in
|
||||||
|
/// `Game::attribute`, weil sie beim **Verteilen** einmal passiert und nicht
|
||||||
|
/// bei jedem Auslesen: Ein Punkt in Konstitution hebt den Grundwert
|
||||||
|
/// `max_health`, wie es Ticket 15 fuer die Zuwachs-Regel voraussetzt.
|
||||||
|
pub damage_per_strength: f32,
|
||||||
|
pub health_per_constitution: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Balance {
|
impl Default for Balance {
|
||||||
@@ -233,6 +250,11 @@ impl Default for Balance {
|
|||||||
hit_ceiling: 0.95,
|
hit_ceiling: 0.95,
|
||||||
verkauf: 0.4,
|
verkauf: 0.4,
|
||||||
charisma_preis: 1.5,
|
charisma_preis: 1.5,
|
||||||
|
exp_base: 20.0,
|
||||||
|
exp_exponent: 1.5,
|
||||||
|
luck_factor: 0.02,
|
||||||
|
damage_per_strength: 1.0,
|
||||||
|
health_per_constitution: 8.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ pub enum Target {
|
|||||||
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
|
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
|
||||||
/// M3 braucht fuenf davon.
|
/// M3 braucht fuenf davon.
|
||||||
///
|
///
|
||||||
// TODO(M5+): `give_item`, `remove_item`, `start_quest` (08), `give_exp` (15),
|
// TODO(M6+): `give_item`, `remove_item`, `start_quest` (08), `run_script` (13).
|
||||||
// `run_script` (13).
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(tag = "action", rename_all = "snake_case")]
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
@@ -112,6 +111,12 @@ pub enum Action {
|
|||||||
/// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht
|
/// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht
|
||||||
/// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
|
/// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
|
||||||
OpenShop,
|
OpenShop,
|
||||||
|
/// Zwoelfte Aktion (Ticket 15): Erfahrung fuer Questbelohnungen. Der
|
||||||
|
/// zweite Weg neben `exp_reward` — das ist die Kurzform fuer einen
|
||||||
|
/// Todes-Trigger, den sonst jede Gegnerdefinition mitschleppen muesste.
|
||||||
|
GiveExp {
|
||||||
|
amount: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
||||||
@@ -130,7 +135,7 @@ impl Action {
|
|||||||
| Action::ChangeAttribute { target, .. }
|
| Action::ChangeAttribute { target, .. }
|
||||||
| Action::ApplyModifier { target, .. }
|
| Action::ApplyModifier { target, .. }
|
||||||
| Action::RemoveModifier { target, .. } => *target,
|
| Action::RemoveModifier { target, .. } => *target,
|
||||||
Action::Flee | Action::OpenShop => Target::Zelf,
|
Action::Flee | Action::OpenShop | Action::GiveExp { .. } => Target::Zelf,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ impl Attribute {
|
|||||||
Attribute::Resistance,
|
Attribute::Resistance,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Worauf ein Aufstiegspunkt gesetzt werden darf (Ticket 15). Abgeleitete
|
||||||
|
/// Groessen wie `damage` fehlen bewusst: Punkte direkt dorthin zu setzen
|
||||||
|
/// umginge das Modifikatorsystem aus Ticket 01.
|
||||||
|
pub const ASSIGNABLE: [Attribute; 5] = [
|
||||||
|
Attribute::Strength,
|
||||||
|
Attribute::Dexterity,
|
||||||
|
Attribute::Constitution,
|
||||||
|
Attribute::Charisma,
|
||||||
|
Attribute::Luck,
|
||||||
|
];
|
||||||
|
|
||||||
pub fn name(self) -> &'static str {
|
pub fn name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Attribute::Strength => "Stärke",
|
Attribute::Strength => "Stärke",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ pub enum Direction {
|
|||||||
|
|
||||||
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
||||||
///
|
///
|
||||||
// TODO(M5+): `AssignPoints` aus Ticket 15.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Move(Direction),
|
Move(Direction),
|
||||||
@@ -44,6 +43,10 @@ pub enum Command {
|
|||||||
ability: String,
|
ability: String,
|
||||||
target: super::creature::CreatureRef,
|
target: super::creature::CreatureRef,
|
||||||
},
|
},
|
||||||
|
/// **Alle** Punkte auf einmal und damit **ein** Zug (Ticket 15). Ein
|
||||||
|
/// Kommando je Punkt hiesse bei drei Stufen drei Zuege, in denen ein Gift
|
||||||
|
/// tickt, waehrend man ein Menue bedient.
|
||||||
|
AssignPoints(Vec<(super::attributes::Attribute, u32)>),
|
||||||
/// Werkbank. **Ein** Kommando mit einer Variantenliste statt fuenf
|
/// Werkbank. **Ein** Kommando mit einer Variantenliste statt fuenf
|
||||||
/// Kommandos, die sich durch `rejects` und `perform` ziehen — ein Cheat
|
/// Kommandos, die sich durch `rejects` und `perform` ziehen — ein Cheat
|
||||||
/// wird nie abgewiesen, das ist sein ganzer Zweck.
|
/// wird nie abgewiesen, das ist sein ganzer Zweck.
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
|||||||
.npcs
|
.npcs
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.and_then(|n| n.shop.as_deref())
|
.and_then(|n| n.shop.as_deref())
|
||||||
.map(|t| crate::game::loot::roll(t, reg, rng))
|
.map(|t| crate::game::loot::roll(t, 0.0, reg, rng))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
Some(super::floor::NpcInstance { pos, def: id, stock })
|
Some(super::floor::NpcInstance { pos, def: id, stock })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,14 +15,26 @@ use super::rng::Rng;
|
|||||||
/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer
|
/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer
|
||||||
/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst
|
/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst
|
||||||
/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund.
|
/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund.
|
||||||
pub fn roll(table: &str, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
|
/// `luck` ist der **gerechnete** Wert des Ziehenden — 0.0, wenn niemand zieht
|
||||||
|
/// (ein Haendlerregal hat kein Glueck).
|
||||||
|
pub fn roll(table: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
draw(table, reg, rng, 0, &mut out);
|
draw(table, luck, reg, rng, 0, &mut out);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `luck` hat genau **eine** Regel (Ticket 16): eine Chance auf einen
|
||||||
|
/// zusaetzlichen Zug — auf jeder Tabelle, und deshalb auch auf jeder
|
||||||
|
/// Affixtabelle. Sie wirkt dreifach, ohne dreimal geschrieben zu werden: mehr
|
||||||
|
/// Gegenstaende, mehr Affixe, und haeufiger die staerkere Stufe, weil die als
|
||||||
|
/// eigener Eintrag mit kleinem Gewicht danebensteht.
|
||||||
|
fn extra_roll(luck: f32, reg: &Registries, rng: &mut Rng) -> u32 {
|
||||||
|
u32::from(rng.chance((luck * reg.balance.luck_factor).clamp(0.0, 0.9)))
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(
|
fn draw(
|
||||||
table_id: &str,
|
table_id: &str,
|
||||||
|
luck: f32,
|
||||||
reg: &Registries,
|
reg: &Registries,
|
||||||
rng: &mut Rng,
|
rng: &mut Rng,
|
||||||
depth: u8,
|
depth: u8,
|
||||||
@@ -35,7 +47,7 @@ fn draw(
|
|||||||
|
|
||||||
// `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer.
|
// `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer.
|
||||||
for entry in table.entries.iter().filter(|e| e.guaranteed) {
|
for entry in table.entries.iter().filter(|e| e.guaranteed) {
|
||||||
emit(entry, reg, rng, depth, out);
|
emit(entry, luck, reg, rng, depth, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool: Vec<&crate::data::LootEntry> =
|
let pool: Vec<&crate::data::LootEntry> =
|
||||||
@@ -46,16 +58,17 @@ fn draw(
|
|||||||
let weights: Vec<u32> = pool.iter().map(|e| e.weight).collect();
|
let weights: Vec<u32> = pool.iter().map(|e| e.weight).collect();
|
||||||
|
|
||||||
let (lo, hi) = parse_range(table.rolls.as_ref());
|
let (lo, hi) = parse_range(table.rolls.as_ref());
|
||||||
let rolls = rng.range(lo, hi);
|
let rolls = rng.range(lo, hi) + extra_roll(luck, reg, rng);
|
||||||
for _ in 0..rolls {
|
for _ in 0..rolls {
|
||||||
if let Some(i) = rng.weighted(&weights) {
|
if let Some(i) = rng.weighted(&weights) {
|
||||||
emit(pool[i], reg, rng, depth, out);
|
emit(pool[i], luck, reg, rng, depth, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit(
|
fn emit(
|
||||||
entry: &crate::data::LootEntry,
|
entry: &crate::data::LootEntry,
|
||||||
|
luck: f32,
|
||||||
reg: &Registries,
|
reg: &Registries,
|
||||||
rng: &mut Rng,
|
rng: &mut Rng,
|
||||||
depth: u8,
|
depth: u8,
|
||||||
@@ -67,7 +80,7 @@ fn emit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sub) = &entry.table {
|
if let Some(sub) = &entry.table {
|
||||||
draw(sub, reg, rng, depth + 1, out);
|
draw(sub, luck, reg, rng, depth + 1, out);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +96,7 @@ fn emit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let affixes = match &entry.affixes {
|
let affixes = match &entry.affixes {
|
||||||
Some(table) => affixes_for(table, reg, rng),
|
Some(table) => affixes_for(table, luck, reg, rng),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,13 +124,13 @@ fn emit(
|
|||||||
/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene
|
/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene
|
||||||
/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug
|
/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug
|
||||||
/// (Glueck) auch die Chance auf die staerkere Stufe.
|
/// (Glueck) auch die Chance auf die staerkere Stufe.
|
||||||
fn affixes_for(table_id: &str, reg: &Registries, rng: &mut Rng) -> Vec<String> {
|
fn affixes_for(table_id: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec<String> {
|
||||||
let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() };
|
let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() };
|
||||||
let weights: Vec<u32> = table.entries.iter().map(|e| e.weight).collect();
|
let weights: Vec<u32> = table.entries.iter().map(|e| e.weight).collect();
|
||||||
|
|
||||||
let (lo, hi) = parse_range(table.rolls.as_ref());
|
let (lo, hi) = parse_range(table.rolls.as_ref());
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for _ in 0..rng.range(lo, hi) {
|
for _ in 0..rng.range(lo, hi) + extra_roll(luck, reg, rng) {
|
||||||
let Some(i) = rng.weighted(&weights) else { continue };
|
let Some(i) = rng.weighted(&weights) else { continue };
|
||||||
let entry = &table.entries[i];
|
let entry = &table.entries[i];
|
||||||
if entry.nothing {
|
if entry.nothing {
|
||||||
@@ -143,7 +156,7 @@ mod tests {
|
|||||||
let (reg, _) = load_mods().expect("core");
|
let (reg, _) = load_mods().expect("core");
|
||||||
let mut rng = Rng::new(1);
|
let mut rng = Rng::new(1);
|
||||||
// Eine Tabelle, die es nicht gibt, ist kein Absturzgrund.
|
// Eine Tabelle, die es nicht gibt, ist kein Absturzgrund.
|
||||||
assert!(roll("gibtsnicht", ®, &mut rng).is_empty());
|
assert!(roll("gibtsnicht", 0.0, ®, &mut rng).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -151,7 +164,7 @@ mod tests {
|
|||||||
let (reg, _) = load_mods().expect("core");
|
let (reg, _) = load_mods().expect("core");
|
||||||
for seed in 1..30u64 {
|
for seed in 1..30u64 {
|
||||||
let mut rng = Rng::new(seed);
|
let mut rng = Rng::new(seed);
|
||||||
let beute = roll("core:boss_table", ®, &mut rng);
|
let beute = roll("core:boss_table", 0.0, ®, &mut rng);
|
||||||
assert!(
|
assert!(
|
||||||
beute.iter().any(|i| i.def == "core:bone_blade"),
|
beute.iter().any(|i| i.def == "core:bone_blade"),
|
||||||
"seed {seed}: das Einzelstueck muss fallen"
|
"seed {seed}: das Einzelstueck muss fallen"
|
||||||
@@ -165,7 +178,7 @@ mod tests {
|
|||||||
let mut mit_affix = 0;
|
let mut mit_affix = 0;
|
||||||
for seed in 1..80u64 {
|
for seed in 1..80u64 {
|
||||||
let mut rng = Rng::new(seed);
|
let mut rng = Rng::new(seed);
|
||||||
for inst in roll("core:floor_5_table", ®, &mut rng) {
|
for inst in roll("core:floor_5_table", 0.0, ®, &mut rng) {
|
||||||
if !inst.affixes.is_empty() {
|
if !inst.affixes.is_empty() {
|
||||||
mit_affix += 1;
|
mit_affix += 1;
|
||||||
// Jeder gezogene Affix muss existieren.
|
// Jeder gezogene Affix muss existieren.
|
||||||
@@ -179,8 +192,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn derselbe_seed_liefert_dieselbe_beute() {
|
fn derselbe_seed_liefert_dieselbe_beute() {
|
||||||
let (reg, _) = load_mods().expect("core");
|
let (reg, _) = load_mods().expect("core");
|
||||||
let a = roll("core:floor_3_table", ®, &mut Rng::new(77));
|
let a = roll("core:floor_3_table", 0.0, ®, &mut Rng::new(77));
|
||||||
let b = roll("core:floor_3_table", ®, &mut Rng::new(77));
|
let b = roll("core:floor_3_table", 0.0, ®, &mut Rng::new(77));
|
||||||
let ids = |v: &[ItemInstance]| {
|
let ids = |v: &[ItemInstance]| {
|
||||||
v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::<Vec<_>>()
|
v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
@@ -217,6 +230,6 @@ mod tests {
|
|||||||
reg.loot_tables.insert(String::from("b"), tabelle("b", "a"));
|
reg.loot_tables.insert(String::from("b"), tabelle("b", "a"));
|
||||||
|
|
||||||
// Terminiert, statt den Stack zu sprengen.
|
// Terminiert, statt den Stack zu sprengen.
|
||||||
assert!(roll("a", ®, &mut Rng::new(5)).is_empty());
|
assert!(roll("a", 0.0, ®, &mut Rng::new(5)).is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
213
src/game/mod.rs
213
src/game/mod.rs
@@ -53,8 +53,13 @@ pub struct Game {
|
|||||||
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
|
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
|
||||||
/// `game/` kennt keine Screens.
|
/// `game/` kennt keine Screens.
|
||||||
pub open_shop: bool,
|
pub open_shop: bool,
|
||||||
// TODO(M5): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
|
/// Erfahrung und unverteilte Punkte stehen an `Game`, **nicht am
|
||||||
// `Game`, nicht am `Creature`).
|
/// `Creature`** (Ticket 15): Nur der Held hat beides, und ein Feld, das
|
||||||
|
/// fuer 99 % der Exemplare bedeutungslos ist, waere ein Kommentar in
|
||||||
|
/// Feldform — den der Speicherstand fuer jeden Gegner mitschriebe.
|
||||||
|
pub current_exp: u32,
|
||||||
|
pub unspent_points: u32,
|
||||||
|
// TODO(M6): `quests` (Ticket 08).
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
@@ -80,6 +85,8 @@ impl Game {
|
|||||||
journal: Vec::new(),
|
journal: Vec::new(),
|
||||||
talking_to: None,
|
talking_to: None,
|
||||||
open_shop: false,
|
open_shop: false,
|
||||||
|
current_exp: 0,
|
||||||
|
unspent_points: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +342,7 @@ impl Game {
|
|||||||
self.events.push_back(
|
self.events.push_back(
|
||||||
Event::new(EventKind::DamageTaken, victim).to(source).with_damage(*damage_type),
|
Event::new(EventKind::DamageTaken, victim).to(source).with_damage(*damage_type),
|
||||||
);
|
);
|
||||||
self.check_death(victim, source);
|
self.check_death(victim, source, reg);
|
||||||
}
|
}
|
||||||
|
|
||||||
Action::ChangeAttribute { attribute, amount, .. } => {
|
Action::ChangeAttribute { attribute, amount, .. } => {
|
||||||
@@ -350,7 +357,7 @@ impl Game {
|
|||||||
} else {
|
} else {
|
||||||
c.stamina = (c.stamina + amount).clamp(0.0, max);
|
c.stamina = (c.stamina + amount).clamp(0.0, max);
|
||||||
}
|
}
|
||||||
self.check_death(victim, source);
|
self.check_death(victim, source, reg);
|
||||||
}
|
}
|
||||||
|
|
||||||
Action::ApplyModifier { modifier, .. } => {
|
Action::ApplyModifier { modifier, .. } => {
|
||||||
@@ -383,6 +390,10 @@ impl Game {
|
|||||||
// die Schleife schiebt den Screen. `game/` kennt ratatui nicht.
|
// die Schleife schiebt den Screen. `game/` kennt ratatui nicht.
|
||||||
Action::OpenShop => self.open_shop = true,
|
Action::OpenShop => self.open_shop = true,
|
||||||
|
|
||||||
|
// Der zweite Weg zur Erfahrung — fuer Questbelohnungen. Immer an
|
||||||
|
// den Helden: Ein Gegner hat keine Erfahrung, die ihm nuetzt.
|
||||||
|
Action::GiveExp { amount } => self.current_exp += amount,
|
||||||
|
|
||||||
Action::Flee => {
|
Action::Flee => {
|
||||||
let own = self.attribute(source, Attribute::Dexterity, None, reg);
|
let own = self.attribute(source, Attribute::Dexterity, None, reg);
|
||||||
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
|
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
|
||||||
@@ -405,12 +416,29 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_death(&mut self, who: CreatureRef, killer: CreatureRef) {
|
fn check_death(&mut self, who: CreatureRef, killer: CreatureRef, reg: &Registries) {
|
||||||
if self.creature(who).is_alive() {
|
if self.creature(who).is_alive() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let name = self.creature(who).name.clone();
|
let name = self.creature(who).name.clone();
|
||||||
self.log(format!("{name} fällt."));
|
self.log(format!("{name} fällt."));
|
||||||
|
|
||||||
|
// `exp_reward` ist die **Kurzform** fuer einen Todes-Trigger, den sonst
|
||||||
|
// jede Gegnerdefinition mitschleppen muesste — samt einer
|
||||||
|
// `is_self`-Pruefung, die es gar nicht gibt (Ticket 15).
|
||||||
|
if who != CreatureRef::Player {
|
||||||
|
let lohn = self
|
||||||
|
.creature(who)
|
||||||
|
.def
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| reg.creatures.get(id))
|
||||||
|
.map_or(0, |d| d.exp_reward);
|
||||||
|
if lohn > 0 {
|
||||||
|
self.current_exp += lohn;
|
||||||
|
self.log(format!("{lohn} Erfahrung"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.events.push_back(Event::new(EventKind::CreatureDied, who).to(killer));
|
self.events.push_back(Event::new(EventKind::CreatureDied, who).to(killer));
|
||||||
|
|
||||||
if who == CreatureRef::Player {
|
if who == CreatureRef::Player {
|
||||||
@@ -523,13 +551,17 @@ impl Game {
|
|||||||
self.drain_events(reg);
|
self.drain_events(reg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Ansagen fuer den naechsten Zug. **Nach** dem Entfernen der Toten,
|
// 6. Aufstieg — **nach** dem Leerarbeiten, damit eine Questbelohnung
|
||||||
|
// noch im selben Zug wirkt (Ticket 15).
|
||||||
|
self.level_up(reg);
|
||||||
|
|
||||||
|
// 7. Ansagen fuer den naechsten Zug. **Nach** dem Entfernen der Toten,
|
||||||
// sonst sagt eine Leiche an.
|
// sonst sagt eine Leiche an.
|
||||||
for i in 0..self.enemies.len() {
|
for i in 0..self.enemies.len() {
|
||||||
self.choose_intent(i, reg);
|
self.choose_intent(i, reg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7.
|
// 8.
|
||||||
self.turn += 1;
|
self.turn += 1;
|
||||||
Outcome::TurnPassed
|
Outcome::TurnPassed
|
||||||
}
|
}
|
||||||
@@ -599,6 +631,19 @@ impl Game {
|
|||||||
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
|
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
|
||||||
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
|
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
|
||||||
.then_some("Gold verkauft man nicht"),
|
.then_some("Gold verkauft man nicht"),
|
||||||
|
Command::AssignPoints(punkte) => {
|
||||||
|
let summe: u32 = punkte.iter().map(|(_, n)| n).sum();
|
||||||
|
if summe == 0 {
|
||||||
|
return Some("Nichts zu verteilen");
|
||||||
|
}
|
||||||
|
if summe > self.unspent_points {
|
||||||
|
return Some("Nicht genug Punkte");
|
||||||
|
}
|
||||||
|
punkte
|
||||||
|
.iter()
|
||||||
|
.any(|(a, _)| !Attribute::ASSIGNABLE.contains(a))
|
||||||
|
.then_some("Darauf nicht verteilbar")
|
||||||
|
}
|
||||||
// Ein Cheat wird nie abgewiesen — das ist sein Zweck.
|
// Ein Cheat wird nie abgewiesen — das ist sein Zweck.
|
||||||
Command::Debug(_) => None,
|
Command::Debug(_) => None,
|
||||||
Command::Wait => None,
|
Command::Wait => None,
|
||||||
@@ -748,6 +793,7 @@ impl Game {
|
|||||||
Command::Choose(i) => self.choose(*i, reg),
|
Command::Choose(i) => self.choose(*i, reg),
|
||||||
Command::Buy(i) => self.buy(*i, reg),
|
Command::Buy(i) => self.buy(*i, reg),
|
||||||
Command::Sell(i) => self.sell(*i, reg),
|
Command::Sell(i) => self.sell(*i, reg),
|
||||||
|
Command::AssignPoints(punkte) => self.assign_points(punkte, reg),
|
||||||
Command::Debug(cheat) => self.cheat(cheat, reg),
|
Command::Debug(cheat) => self.cheat(cheat, reg),
|
||||||
Command::Drop(i) => {
|
Command::Drop(i) => {
|
||||||
let inst = self.player.inventory.remove(*i);
|
let inst = self.player.inventory.remove(*i);
|
||||||
@@ -770,7 +816,8 @@ impl Game {
|
|||||||
let table = self.floor.rooms[room].loot_table.clone();
|
let table = self.floor.rooms[room].loot_table.clone();
|
||||||
self.floor.rooms[room].loot.retain(|p| *p != (x, y));
|
self.floor.rooms[room].loot.retain(|p| *p != (x, y));
|
||||||
|
|
||||||
let beute = loot::roll(&table, reg, &mut self.rng);
|
let glueck = self.attribute(CreatureRef::Player, Attribute::Luck, None, reg);
|
||||||
|
let beute = loot::roll(&table, glueck, reg, &mut self.rng);
|
||||||
if beute.is_empty() {
|
if beute.is_empty() {
|
||||||
// Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer
|
// Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer
|
||||||
// Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen
|
// Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen
|
||||||
@@ -840,6 +887,73 @@ impl Game {
|
|||||||
self.log(format!("{name} verkauft fuer {preis} Gold"));
|
self.log(format!("{name} verkauft fuer {preis} Gold"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was der naechste Aufstieg kostet: `exp_base × level^exp_exponent`.
|
||||||
|
pub fn exp_needed(&self, reg: &Registries) -> u32 {
|
||||||
|
let level = self.player.base.get(Attribute::Level).max(1.0);
|
||||||
|
(reg.balance.exp_base * level.powf(reg.balance.exp_exponent)).round() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steigt auf, **solange** die Erfahrung reicht: Drei Stufen geben drei
|
||||||
|
/// Punkte. Eine Stufe je Ereignis erzeugte die Frage, wann der Rest
|
||||||
|
/// nachgeholt wird, plus einen Zustand „aufstiegsbereit" (Ticket 15).
|
||||||
|
///
|
||||||
|
/// Keine Obergrenze — fuenfzig Stufen ueber fuenf Etagen waeren bedeutungslos,
|
||||||
|
/// und eine harte Grenze waere der einzige Teil der Progression, der einen
|
||||||
|
/// spaeteren Endlosmodus blockierte.
|
||||||
|
/// Der Aufstieg selbst hebt **keinen** Lebensstand: Ticket 15 nennt zwar
|
||||||
|
/// zwei Stellen, aber es hat `per_level` gestrichen — eine Stufe aendert
|
||||||
|
/// `max_health` nicht, das tut erst der Punkt in Konstitution.
|
||||||
|
fn level_up(&mut self, reg: &Registries) {
|
||||||
|
loop {
|
||||||
|
let noetig = self.exp_needed(reg);
|
||||||
|
if self.current_exp < noetig {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.current_exp -= noetig;
|
||||||
|
let neu = self.player.base.get(Attribute::Level) + 1.0;
|
||||||
|
self.player.base.set(Attribute::Level, neu);
|
||||||
|
self.unspent_points += 1;
|
||||||
|
self.log(format!("Stufe {neu:.0}! Ein Punkt zu verteilen."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die **einzige** Stelle, an der der Lebensstand einem Zuwachs folgt
|
||||||
|
/// (Ticket 15). Keine allgemeine Regel: Sonst heilte ein Ring beim Anlegen.
|
||||||
|
/// `rejects` hat Deckung und Zielattribute geprueft.
|
||||||
|
fn assign_points(&mut self, punkte: &[(Attribute, u32)], reg: &Registries) {
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
let vorher = self.attribute(p, Attribute::MaxHealth, None, reg);
|
||||||
|
|
||||||
|
for (attr, n) in punkte {
|
||||||
|
let menge = *n as f32;
|
||||||
|
let alt = self.player.base.get(*attr);
|
||||||
|
self.player.base.set(*attr, alt + menge);
|
||||||
|
self.unspent_points -= n;
|
||||||
|
|
||||||
|
// Zwei Grundwerte wirken nicht von allein — siehe `Balance`.
|
||||||
|
match attr {
|
||||||
|
Attribute::Strength => {
|
||||||
|
let d = self.player.base.get(Attribute::Damage);
|
||||||
|
self.player
|
||||||
|
.base
|
||||||
|
.set(Attribute::Damage, d + menge * reg.balance.damage_per_strength);
|
||||||
|
}
|
||||||
|
Attribute::Constitution => {
|
||||||
|
let h = self.player.base.get(Attribute::MaxHealth);
|
||||||
|
self.player.base.set(
|
||||||
|
Attribute::MaxHealth,
|
||||||
|
h + menge * reg.balance.health_per_constitution,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nachher = self.attribute(p, Attribute::MaxHealth, None, reg);
|
||||||
|
self.player.health += (nachher - vorher).max(0.0);
|
||||||
|
self.log(String::from("Punkte verteilt"));
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Werkbank. Steht im Spielkern und nicht in der UI, weil sie durch
|
/// Die Werkbank. Steht im Spielkern und nicht in der UI, weil sie durch
|
||||||
/// dieselbe Tuer muss wie alles andere: `Command` ist der einzige Weg
|
/// dieselbe Tuer muss wie alles andere: `Command` ist der einzige Weg
|
||||||
/// hinein (Ticket 11).
|
/// hinein (Ticket 11).
|
||||||
@@ -863,10 +977,15 @@ impl Game {
|
|||||||
self.descend(reg);
|
self.descend(reg);
|
||||||
}
|
}
|
||||||
Cheat::ClearRoom => {
|
Cheat::ClearRoom => {
|
||||||
for e in &mut self.enemies {
|
|
||||||
e.health = 0.0;
|
|
||||||
}
|
|
||||||
self.log(String::from("[debug] Raum geleert"));
|
self.log(String::from("[debug] Raum geleert"));
|
||||||
|
for i in 0..self.enemies.len() {
|
||||||
|
self.enemies[i].health = 0.0;
|
||||||
|
// Ueber `check_death`, nicht nur ueber den Lebensstand:
|
||||||
|
// Sonst faellt der Gegner still um, ohne Todes-Trigger und
|
||||||
|
// ohne Erfahrung — und der Cheat testete den Weg nicht,
|
||||||
|
// den er testen soll.
|
||||||
|
self.check_death(CreatureRef::Enemy(i), CreatureRef::Player, reg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1771,4 +1890,76 @@ mod tests {
|
|||||||
assert_eq!(game.turn, vorher + 1, "und er kostet den Zug");
|
assert_eq!(game.turn, vorher + 1, "und er kostet den Zug");
|
||||||
assert_eq!(game.count_of("core:health_potion"), 1);
|
assert_eq!(game.count_of("core:health_potion"), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der ganze Kreis aus Ticket 15: toeten → Erfahrung → Aufstieg →
|
||||||
|
/// verteilen → staerker. Und die Zuwachs-Regel: `max_health` steigt erst,
|
||||||
|
/// wenn `constitution` **verteilt** wird, nicht schon beim Aufstieg.
|
||||||
|
#[test]
|
||||||
|
fn erfahrung_steigt_auf_und_punkte_wirken() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
let noetig = game.exp_needed(®);
|
||||||
|
|
||||||
|
// Drei Stufen auf einmal: Es wird in einer Schleife aufgestiegen, nicht
|
||||||
|
// eine Stufe je Ereignis (Ticket 15).
|
||||||
|
game.apply(
|
||||||
|
&Action::GiveExp { amount: noetig * 10 },
|
||||||
|
CreatureRef::Player,
|
||||||
|
CreatureRef::Player,
|
||||||
|
®,
|
||||||
|
);
|
||||||
|
game.execute(Command::Wait, ®);
|
||||||
|
let stufe = game.player.base.get(Attribute::Level);
|
||||||
|
assert!(stufe >= 3.0, "zehnfache Erfahrung muss mehrere Stufen geben, war {stufe}");
|
||||||
|
assert_eq!(game.unspent_points as f32, stufe - 1.0, "je Stufe genau ein Punkt");
|
||||||
|
|
||||||
|
// Der Aufstieg allein hebt `max_health` nicht — das tut erst der Punkt.
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
let max_vorher = game.attribute(p, Attribute::MaxHealth, None, ®);
|
||||||
|
let leben_vorher = game.player.health;
|
||||||
|
|
||||||
|
let punkte = game.unspent_points;
|
||||||
|
let cmd = Command::AssignPoints(vec![(Attribute::Constitution, punkte)]);
|
||||||
|
assert!(matches!(game.execute(cmd, ®), Outcome::TurnPassed));
|
||||||
|
|
||||||
|
let max_nachher = game.attribute(p, Attribute::MaxHealth, None, ®);
|
||||||
|
assert!(
|
||||||
|
max_nachher > max_vorher,
|
||||||
|
"Konstitution muss wirken — sonst ist der Punkt eine Falle"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
game.player.health - leben_vorher,
|
||||||
|
max_nachher - max_vorher,
|
||||||
|
"der Lebensstand folgt dem Zuwachs"
|
||||||
|
);
|
||||||
|
assert_eq!(game.unspent_points, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rejects` haelt die Grenzen: mehr Punkte als vorhanden, und Attribute,
|
||||||
|
/// die gar nicht verteilbar sind.
|
||||||
|
#[test]
|
||||||
|
fn punkte_lassen_sich_nicht_erfinden() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
game.unspent_points = 1;
|
||||||
|
|
||||||
|
let zuviel = Command::AssignPoints(vec![(Attribute::Strength, 2)]);
|
||||||
|
assert!(matches!(game.execute(zuviel, ®), Outcome::Rejected(_)));
|
||||||
|
|
||||||
|
// `damage` ist abgeleitet — ein Punkt dorthin umginge das
|
||||||
|
// Modifikatorsystem aus Ticket 01.
|
||||||
|
let falsch = Command::AssignPoints(vec![(Attribute::Damage, 1)]);
|
||||||
|
assert!(matches!(game.execute(falsch, ®), Outcome::Rejected(_)));
|
||||||
|
assert_eq!(game.unspent_points, 1, "eine Abweisung darf nichts verbrauchen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `exp_reward` stand seit M3 an jeder Gegnerdefinition und las es niemand.
|
||||||
|
#[test]
|
||||||
|
fn ein_toter_gegner_zahlt_erfahrung() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
let ratte = reg.creatures.get("core:rat").expect("Riesenratte");
|
||||||
|
assert!(ratte.exp_reward > 0, "die Testdaten muessen etwas zahlen");
|
||||||
|
game.enemies = vec![Creature::from_def(ratte)];
|
||||||
|
|
||||||
|
game.execute(Command::Debug(Cheat::ClearRoom), ®);
|
||||||
|
assert_eq!(game.current_exp, ratte.exp_reward);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,16 @@ impl Rng {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn chance(&mut self, p: f32) -> bool {
|
pub fn chance(&mut self, p: f32) -> bool {
|
||||||
(self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64
|
// Eine Chance von null ist keine Frage an den Wuerfel. Ohne das
|
||||||
|
// verbraucht ein `chance(0.0)` eine Zahl und verschiebt den ganzen
|
||||||
|
// Strom — ein Held ohne Glueck erzeugte eine andere Etage als einer mit.
|
||||||
|
if p <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if p >= 1.0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
(self.next_u64() % 10_000) < (p * 10_000.0) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zieht einen Index gewichtet. Gewichte statt Prozente — sie komponieren,
|
/// Zieht einen Index gewichtet. Gewichte statt Prozente — sie komponieren,
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ fn draw(frame: &mut Frame, app: &App) {
|
|||||||
draw_combat(frame, area, app, *pick, *target, *targeting)
|
draw_combat(frame, area, app, *pick, *target, *targeting)
|
||||||
}
|
}
|
||||||
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
|
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
|
||||||
Screen::CharacterSheet => draw_sheet(frame, area, app),
|
Screen::CharacterSheet { pick, pending } => {
|
||||||
|
draw_sheet(frame, area, app, *pick, pending)
|
||||||
|
}
|
||||||
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
|
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
|
||||||
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
|
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
|
||||||
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
|
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
|
||||||
@@ -530,7 +532,7 @@ fn draw_game_over(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
|
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
|
||||||
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
|
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
|
||||||
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App, pick: usize, pending: &[u32; 5]) {
|
||||||
let block = Block::bordered().title(" Charakterbogen ");
|
let block = Block::bordered().title(" Charakterbogen ");
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
@@ -554,7 +556,16 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
} else {
|
} else {
|
||||||
format!("{total:.0} ({base:.0} Basis)")
|
format!("{total:.0} ({base:.0} Basis)")
|
||||||
};
|
};
|
||||||
Row::new(vec![a.name().to_string(), cell])
|
|
||||||
|
// Verteilbare Grundwerte tragen Cursor und Vormerkung — der Bogen
|
||||||
|
// ist zugleich der Verteil-Screen (Ticket 15).
|
||||||
|
let Some(i) = Attribute::ASSIGNABLE.iter().position(|x| x == a) else {
|
||||||
|
return Row::new(vec![format!(" {}", a.name()), cell]);
|
||||||
|
};
|
||||||
|
let zeiger = if i == pick { "›" } else { " " };
|
||||||
|
let vorgemerkt = if pending[i] > 0 { format!(" +{}", pending[i]) } else { String::new() };
|
||||||
|
Row::new(vec![format!("{zeiger} {} {vorgemerkt}", a.name()), cell])
|
||||||
|
.style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset }))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -566,6 +577,21 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Erfahrung und Punkte stehen an `Game`, nicht am `Creature` (Ticket 15).
|
||||||
|
let offen = app.game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
"Erfahrung {}/{}",
|
||||||
|
app.game.current_exp,
|
||||||
|
app.game.exp_needed(&app.registries)
|
||||||
|
)));
|
||||||
|
if app.game.unspent_points > 0 {
|
||||||
|
lines.push(
|
||||||
|
Line::from(format!("{offen} Punkte offen · ←→ verteilen · Enter bestätigen"))
|
||||||
|
.style(Style::new().fg(Color::Yellow)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
lines.push(Line::from("Herkunft".bold()));
|
lines.push(Line::from("Herkunft".bold()));
|
||||||
for id in &app.game.player.options {
|
for id in &app.game.player.options {
|
||||||
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);
|
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);
|
||||||
|
|||||||
105
src/ui/screen.rs
105
src/ui/screen.rs
@@ -3,7 +3,7 @@
|
|||||||
use ratatui::crossterm::event::KeyCode;
|
use ratatui::crossterm::event::KeyCode;
|
||||||
|
|
||||||
use crate::data::Registries;
|
use crate::data::Registries;
|
||||||
use crate::game::{Cheat, Command, CreatureRef, Direction, Game};
|
use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
|
||||||
|
|
||||||
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
||||||
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
||||||
@@ -25,7 +25,15 @@ pub enum Screen {
|
|||||||
Inventory {
|
Inventory {
|
||||||
pick: usize,
|
pick: usize,
|
||||||
},
|
},
|
||||||
CharacterSheet,
|
/// Zugleich der Verteil-Screen — **kein zwoelfter Screen** (Ticket 15).
|
||||||
|
CharacterSheet {
|
||||||
|
/// Cursor in `Attribute::ASSIGNABLE`.
|
||||||
|
pick: usize,
|
||||||
|
/// Vorgemerkte Punkte, noch nicht abgeschickt. Ein festes Feld statt
|
||||||
|
/// eines `Vec`, weil die fuenf verteilbaren Grundwerte eine feste
|
||||||
|
/// Liste sind.
|
||||||
|
pending: [u32; 5],
|
||||||
|
},
|
||||||
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
||||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||||
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
||||||
@@ -64,6 +72,10 @@ pub enum Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Screen {
|
impl Screen {
|
||||||
|
pub fn sheet() -> Screen {
|
||||||
|
Screen::CharacterSheet { pick: 0, pending: [0; 5] }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_overlay(&self) -> bool {
|
pub fn is_overlay(&self) -> bool {
|
||||||
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
|
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
|
||||||
// Regel „ein Command schliesst alle Overlays" nur die Luft.
|
// Regel „ein Command schliesst alle Overlays" nur die Luft.
|
||||||
@@ -104,7 +116,7 @@ impl Screen {
|
|||||||
KeyCode::Char('t') => Response::Command(Command::Talk),
|
KeyCode::Char('t') => Response::Command(Command::Talk),
|
||||||
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
|
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
|
||||||
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
||||||
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
KeyCode::Char('c') => Response::Push(Screen::sheet()),
|
||||||
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
||||||
KeyCode::Esc => Response::Pop,
|
KeyCode::Esc => Response::Pop,
|
||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
@@ -161,7 +173,7 @@ impl Screen {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
KeyCode::Char('.') => Response::Command(Command::Wait),
|
KeyCode::Char('.') => Response::Command(Command::Wait),
|
||||||
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
KeyCode::Char('c') => Response::Push(Screen::sheet()),
|
||||||
// Derselbe Screen wie draussen. `rejects` verbietet
|
// Derselbe Screen wie draussen. `rejects` verbietet
|
||||||
// `UseItem` im Kampf nicht — es fehlte nur die Tuer.
|
// `UseItem` im Kampf nicht — es fehlte nur die Tuer.
|
||||||
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
||||||
@@ -206,10 +218,46 @@ impl Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Screen::CharacterSheet => match key {
|
Screen::CharacterSheet { pick, pending } => {
|
||||||
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
// `saturating_sub`, weil `unspent_points` sich unter dem
|
||||||
_ => Response::None,
|
// offenen Screen aendern kann — ein Aufstieg im selben Zug.
|
||||||
},
|
let offen = game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
|
||||||
|
match key {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
*pick = pick.saturating_sub(1);
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
*pick = (*pick + 1).min(Attribute::ASSIGNABLE.len() - 1);
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('+' | 'l') if offen > 0 => {
|
||||||
|
pending[*pick] += 1;
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('-' | 'h') if pending[*pick] > 0 => {
|
||||||
|
pending[*pick] -= 1;
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
// **Alle** Punkte in einem Kommando und damit in einem Zug
|
||||||
|
// (Ticket 15).
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let punkte = Attribute::ASSIGNABLE
|
||||||
|
.iter()
|
||||||
|
.zip(pending.iter())
|
||||||
|
.filter(|(_, n)| **n > 0)
|
||||||
|
.map(|(a, n)| (*a, *n))
|
||||||
|
.collect();
|
||||||
|
// **Vorgemerktes wird beim Abschicken verbraucht.** Ohne
|
||||||
|
// das behaelt der Screen Punkte, die das Spiel schon
|
||||||
|
// abgezogen hat — und rechnet `0 - 1` auf `u32`.
|
||||||
|
*pending = [0; 5];
|
||||||
|
Response::Command(Command::AssignPoints(punkte))
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('c' | 'q') => Response::Pop,
|
||||||
|
_ => Response::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Screen::Dialog { pick } => {
|
Screen::Dialog { pick } => {
|
||||||
let options = game.dialog_options(reg);
|
let options = game.dialog_options(reg);
|
||||||
@@ -300,3 +348,44 @@ impl Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::data::load_mods;
|
||||||
|
use crate::game::{Creature, Rng};
|
||||||
|
|
||||||
|
fn spiel() -> (Game, Registries) {
|
||||||
|
let (reg, _) = load_mods().expect("core muss laden");
|
||||||
|
let game = Game::new(Creature::new("Test"), ®, Rng::new(1)).expect("Etage 1");
|
||||||
|
(game, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gefunden im Spiel: Nach dem Abschicken stand `unspent_points` auf 0,
|
||||||
|
/// waehrend der Screen den Punkt noch vorgemerkt hielt — `0 - 1` auf `u32`.
|
||||||
|
/// Der erste Test, der **Screen-Zustand gegen Spielzustand** prueft; die
|
||||||
|
/// Trennung aus Ticket 11 macht genau diese Naht zur Fehlerquelle.
|
||||||
|
#[test]
|
||||||
|
fn vorgemerkte_punkte_werden_beim_abschicken_verbraucht() {
|
||||||
|
let (mut game, reg) = spiel();
|
||||||
|
game.unspent_points = 1;
|
||||||
|
let mut screen = Screen::sheet();
|
||||||
|
|
||||||
|
screen.handle_key(KeyCode::Char('+'), &game, ®);
|
||||||
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
||||||
|
assert_eq!(pending[0], 1, "der Punkt ist vorgemerkt");
|
||||||
|
|
||||||
|
let antwort = screen.handle_key(KeyCode::Enter, &game, ®);
|
||||||
|
let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") };
|
||||||
|
assert!(matches!(game.execute(cmd, ®), crate::game::Outcome::TurnPassed));
|
||||||
|
assert_eq!(game.unspent_points, 0);
|
||||||
|
|
||||||
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
||||||
|
assert_eq!(pending[0], 0, "und im Screen nicht mehr vorgemerkt");
|
||||||
|
|
||||||
|
// Der Zustand, der abgestuerzt ist: kein offener Punkt, keine Vormerkung.
|
||||||
|
screen.handle_key(KeyCode::Char('+'), &game, ®);
|
||||||
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
||||||
|
assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user