Zum Inhalt
wannacode

Gegenstände, Inventar, Beute

Kurz gesagt: Gegenstände sind eine Tabelle in Text, die in Objekte gelesen wird; die Ausrüstung addiert Modifikatoren zu den Werten, das Inventar hat Plätze und stapelt Tränke, die Beute wird nach Gewichten gewürfelt, und der Laden kauft und verkauft.

Problem

Ein neues Schwert, ohne den Code anzufassen

In der Mine findet der Held einen Speer. Der Händler verkauft ein Kettenhemd. Der Goblin lässt einen Trank fallen. Wenn jeder Gegenstand eine Zeile Code ist, new Item("spear", "Speer", ...), dann ist jedes neue Schwert eine neue Version des Programms, und ein Spieldesigner, der den Preis des Kettenhemds von 90 auf 80 ändern will, wartet auf eine Kompilierung. Das Spiel hat zweiundzwanzig Gegenstände, und es werden mehr.

Deshalb sind Daten vom Code getrennt. Die Gegenstände sind eine Tabelle in Text: Eine Zeile ist ein Gegenstand, die Zellen sind durch | getrennt, und Zeilen mit # am Anfang sind Notizen.

# id|name|kind|price|modifiers
spear|Speer|weapon|20|accuracy:5
war_axe|Streitaxt|weapon|80|accuracy:-5
leather|Lederrüstung|armor|30|defense:2
chain|Kettenhemd|armor|90|defense:4,evasion:-5
ring_life|Ring des Lebens|ring|70|maxHp:10
ring_agility|Ring des Geschicks|ring|70|agility:2
potion_heal|Heiltrank|potion|15|-
potion_mana|Manatrank|potion|15|-
antidote|Gegengift|potion|10|-
ore|Silbererz|misc|8|-

Die Lektion handelt von fünf Dingen rund um diese Tabelle: wie der Text zu Objekten wird; wie sich die Ausrüstung (Waffe, Rüstung, Ring) zu den Werten addiert; wie das Inventar mit begrenzten Plätzen gleiche Tränke stapelt; wie die Beute aus einer Tabelle mit Gewichten gewürfelt wird; und wie der Laden für Gold kauft und verkauft. Die kleine Tabelle oben hat zehn Gegenstände; im Kanon sind es zweiundzwanzig, aber der Code ist derselbe.

Die Idee

Eine Tabelle in Text, in Objekte gelesen

Der Leser besteht aus drei kleinen Funktionen. parseRows teilt den Text in Zeilen, überspringt leere und Notizen und teilt die Zellen an |; stimmt die Zahl der Zellen nicht, hält es sofort an und sagt, welche Zeile kaputt ist. parseMods liest die Modifikatoren (defense:4,evasion:-5, und - heißt „keine“). loadItems macht aus jeder Zeile einen Gegenstand.

record Mod(string Stat, int Value);
class Item
{
public readonly string Id;
public readonly string Name;
public readonly string Kind;
public readonly int Price;
public readonly List<Mod> Mods;
public readonly bool Stackable;
public Item(string id, string name, string kind, int price, List<Mod> mods)
{
Id = id;
Name = name;
Kind = kind;
Price = price;
Mods = mods;
Stackable = kind == "potion" || kind == "misc";
}
}
// Eine Zeile der Tabelle ist ein Gegenstand: id|Name|Art|Preis|Modifikatoren.
static List<string[]> ParseRows(string text, int columns)
{
var rows = new List<string[]>();
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.Length == 0 || line[0] == '#') continue;
string[] cells = line.Split('|');
if (cells.Length != columns)
{
throw new Exception("erwartet: " + columns + " Zellen in der Zeile: " + line);
}
rows.Add(cells);
}
return rows;
}
// „-“ heißt „keine Modifikatoren“; sonst Paare „Wert:Zahl“, durch Kommas getrennt.
static List<Mod> ParseMods(string text)
{
var mods = new List<Mod>();
if (text == "-") return mods;
foreach (string part in text.Split(','))
{
string[] pieces = part.Split(':');
mods.Add(new Mod(pieces[0], int.Parse(pieces[1])));
}
return mods;
}
static OrderedDictionary<string, Item> LoadItems(string text)
{
var items = new OrderedDictionary<string, Item>();
foreach (string[] c in ParseRows(text, 5))
{
items[c[0]] = new Item(c[0], c[1], c[2], int.Parse(c[3]), ParseMods(c[4]));
}
return items;
}
var items = LoadItems(ITEMS_TEXT);
foreach (Item item in items.Values)
{
Console.WriteLine(item.Name + " (" + item.Kind + ") " + item.Price + " Gold: " + ModsText(item.Mods));
}
Console.WriteLine("gesamt: " + items.Count);
try
{
LoadItems(BAD_ROW);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Ausgabe
Speer (weapon) 20 Gold: accuracy +5
Streitaxt (weapon) 80 Gold: accuracy -5
Lederrüstung (armor) 30 Gold: defense +2
Kettenhemd (armor) 90 Gold: defense +4, evasion -5
Ring des Lebens (ring) 70 Gold: maxHp +10
Ring des Geschicks (ring) 70 Gold: agility +2
Heiltrank (potion) 15 Gold: -
Manatrank (potion) 15 Gold: -
Gegengift (potion) 10 Gold: -
Silbererz (misc) 8 Gold: -
gesamt: 10
erwartet: 5 Zellen in der Zeile: ring_life|Ring des Lebens|ring|70

Das Programm gibt die Gegenstände in der Reihenfolge der Tabelle aus und übergibt am Ende eine Zeile mit fehlender Zelle (dem Preis): Der Leser hält sie mit der genauen Meldung an. Eine kaputte Tabelle muss das Spiel beim Start anhalten, nicht mitten im Kampf, wenn der Spieler zum ersten Mal auf den Gegenstand trifft.

OrderedDictionary<string, Item> ist ein Dictionary, das die Einfügereihenfolge behält (es gibt es seit .NET 9). Ein einfaches Dictionary behält sie in der Praxis auch, solange nichts gelöscht wird, verspricht es aber nicht. Die Tabelle ist ein Raw-String (""").

Hier ist der Leser im Kanon. Dort hat jede Tabelle ihre eigenen Spalten (Waffen sind 10 Zellen, Rüstungen 7), deshalb nimmt parseRows ihre Zahl und den Namen der Tabelle für die Meldung entgegen:

public static List<string[]> ParseRows(string text, int columns, string tableName)
{
List<string[]> rows = new List<string[]>();
foreach (string rawLine in text.Split('\n'))
{
string line = TrimSpaces(rawLine);
if (line.Length == 0 || line[0] == '#') continue;
string[] cells = line.Split('|');
if (cells.Length != columns)
{
throw new Exception(tableName + ": expected " + columns + " cells in: " + line);
}
rows.Add(cells);
}
return rows;
}
public static List<Mod> ParseMods(string text)
{
List<Mod> mods = new List<Mod>();
foreach (string part in SplitList(text))
{
string[] pieces = part.Split(':');
mods.Add(new Mod { Stat = pieces[0], Value = ToInt(pieces[1]) });
}
return mods;
}
Genauer, für Neugierige

Tabellen unterscheiden sich in den Spalten, deshalb hat der Kanon pro Art einen kurzen Leser (loadItems liest die fünf Tabellen nacheinander), aber eines ist gemeinsam: parseRows. Eine neue Art von Daten (Gegner, Fähigkeiten, Dialoge) ist eine neue Tabelle und ein neuer kurzer Leser, nicht eine neue Art zu lesen.

Und hier sind die Daten selbst aus dem Spiel: die Namen in vier Sprachen, dann der Preis und die Modifikatoren.

// Die Daten sind Tabellen in Text: eine Zeile ist ein Eintrag, die Zellen sind durch „|“ getrennt. Zeilen mit „#“
// davor sind Notizen. Der Code liest sie beim Start in Objekte; für ein neues Schwert braucht es eine Zeile, keinen Code.
// Modifikatoren sind „Wert:Zahl“, durch Kommas getrennt; „-“ heißt „nichts“.
static partial class Arcanum
{
public static readonly string WEAPONS_DATA = """
# id|Name|Schadensart|min|max|Preis|Modifikatoren
fists|Fäuste|blunt|1|2|0|-
hand_axe|Handaxt|slash|4|7|20|-
spear|Speer|pierce|3|6|20|accuracy:5
staff|Stab|blunt|2|4|10|maxMana:4
war_axe|Streitaxt|slash|6|10|80|accuracy:-5
longbow|Langbogen|pierce|5|8|80|accuracy:10
mace|Streitkolben|blunt|5|8|60|-
rune_staff|Runenstab|blunt|3|6|75|maxMana:10
silver_sword|Silberschwert|slash|7|11|120|accuracy:5
""";
public static readonly string ARMORS_DATA = """
# id|Name|Preis|Modifikatoren
robe|Robe|5|maxMana:2
leather|Lederrüstung|30|defense:2
chain|Kettenhemd|90|defense:4,evasion:-5
scale|Schuppenpanzer|110|defense:5
""";
public static readonly string RINGS_DATA = """
# id|Name|Preis|Modifikatoren
ring_strength|Ring der Stärke|70|strength:2
ring_agility|Ring des Geschicks|70|agility:2
ring_life|Ring des Lebens|70|maxHp:10
ring_mana|Ring des Manas|70|maxMana:8
""";
public static readonly string POTIONS_DATA = """
# id|Name|Preis|Aktion|Stärke
potion_heal|Heiltrank|15|heal|20
potion_mana|Manatrank|15|mana|15
antidote|Gegengift|10|cure|0
""";
public static readonly string MISC_DATA = """
# id|Name|Preis
ore|Silbererz|8
gold|Gold|0
""";
// Beute: gewichtete Zeilen für jede Tabelle. „none“ ist „nichts“; die Anzahl geht von min bis max.
public static readonly string LOOT_DATA = """
# Tabelle|Gegenstand|Gewicht|min|max
vermin|none|60|0|0
vermin|potion_heal|25|1|1
vermin|antidote|15|1|1
goblin|none|40|0|0
goblin|potion_heal|30|1|1
goblin|potion_mana|10|1|1
goblin|spear|10|1|1
goblin|leather|10|1|1
beast|none|70|0|0
beast|potion_heal|30|1|1
leader|ring_agility|100|1|1
bones|none|50|0|0
bones|mace|20|1|1
bones|potion_heal|30|1|1
cult|potion_mana|40|1|1
cult|potion_heal|40|1|1
cult|rune_staff|20|1|1
golem|ore|100|1|2
warden|none|100|0|0
""";
public static readonly string SHOP_DATA = """
# was der Laden verkauft, in dieser Reihenfolge
potion_heal
potion_mana
antidote
leather
chain
war_axe
longbow
mace
rune_staff
ring_life
ring_mana
""";
// Die Erfahrung für jede Stufe: XP_TABLE[Stufe] ist die Gesamterfahrung, mit der man diese Stufe erreicht.
public static readonly string XP_DATA = "0|0|20|55|105|170|250|345|455|580|720";
}

Die Idee

Ausrüstung: die Basis plus die Gegenstände

Der Held hat drei Plätze: weapon, armor, ring. Ein Platz enthält die id eines Gegenstands oder einen leeren String. Ein Wert wird nicht fertig gespeichert: Er wird jedes Mal als Basis (die Zahl des Helden) plus die Summe der Modifikatoren von allem, was angelegt ist, berechnet. Einen Ring abzunehmen braucht also kein „stell die alte Zahl wieder her“, es wird einfach neu gerechnet.

class Hero
{
public static readonly string[] Slots = ["weapon", "armor", "ring"];
public readonly string Name;
public readonly OrderedDictionary<string, Item> Items;
public readonly Dictionary<string, int> BaseStats = new();
public readonly Dictionary<string, string> Equipment = new();
public int Hp;
public Hero(string name, OrderedDictionary<string, Item> items)
{
Name = name;
Items = items;
foreach (string slot in Slots) Equipment[slot] = "";
}
// Die Basis plus der Bonus von allem, was angelegt ist.
public int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Slots)
{
string itemId = Equipment[slot];
if (itemId.Length == 0) continue;
foreach (Mod mod in Items[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public int Stat(string stat)
{
return BaseStats.GetValueOrDefault(stat, 0) + GearBonus(stat);
}
// Das Leben darf nicht über dem neuen Maximum bleiben.
public void ClampHp()
{
if (Hp > Stat("maxHp")) Hp = Stat("maxHp");
}
public string Equip(string itemId)
{
Item item = Items[itemId];
string old = Equipment[item.Kind];
Equipment[item.Kind] = itemId;
ClampHp();
return old;
}
public void Unequip(string slot)
{
Equipment[slot] = "";
ClampHp();
}
}

equip legt den Gegenstand auf den Platz seiner Art (item.kind) und gibt den alten zurück, damit er in den Beutel zurückgehen kann. Es gibt einen unerwarteten Moment: Gab der Ring +10 maximales Leben und du nimmst ihn ab, kann das aktuelle Leben über dem neuen Maximum bleiben. clampHp holt es wieder herunter.

var items = LoadItems(ITEMS_TEXT);
var hero = new Hero("Iva", items);
hero.BaseStats["maxHp"] = 30;
hero.BaseStats["defense"] = 1;
hero.BaseStats["accuracy"] = 70;
hero.Hp = hero.Stat("maxHp");
Console.WriteLine(hero.Name + ": " + Stats(hero));
foreach (string itemId in new[] { "spear", "leather", "ring_life" })
{
hero.Equip(itemId);
Console.WriteLine("+ " + items[itemId].Name + ": " + Stats(hero));
}
hero.Hp = hero.Stat("maxHp");
Console.WriteLine("Heilung: " + Stats(hero));
hero.Equip("war_axe");
Console.WriteLine("+ " + items["war_axe"].Name + ": " + Stats(hero));
hero.Unequip("ring");
Console.WriteLine("- " + items["ring_life"].Name + ": " + Stats(hero));
Ausgabe
Iva: hp 30/30, defense 1, accuracy 70
+ Speer: hp 30/30, defense 1, accuracy 75
+ Lederrüstung: hp 30/30, defense 3, accuracy 75
+ Ring des Lebens: hp 30/40, defense 3, accuracy 75
Heilung: hp 40/40, defense 3, accuracy 75
+ Streitaxt: hp 40/40, defense 3, accuracy 65
- Ring des Lebens: hp 30/30, defense 3, accuracy 65

Die Zeilen folgen dem Helden: Der Speer addiert 5 zum Treffer, die Lederrüstung 2 zur Abwehr, der Ring 10 zum maximalen Leben (aber das aktuelle Leben ist noch 30/40: Das Maximum wächst, das Leben nicht). Nach der Heilung nimmt die Streitaxt den Platz des Speers ein, und der Treffer sinkt von 75 auf 65 (−5 statt +5). Und das Abnehmen des Rings bringt das maximale Leben auf 30 zurück, und clampHp senkt auch das aktuelle Leben auf 30.

Die Idee

Inventar: Plätze und Stapel

Der Beutel hat eine begrenzte Zahl von Plätzen (im Spiel 10). Ein Gegenstand belegt einen Platz. Aber zehn Heiltränke sind nicht zehn Plätze: Gleiche Tränke und Erz werden gestapelt, auf einem Platz. In der Tabelle sind das die Arten potion und misc (stackable). Waffen und Rüstungen werden nicht gestapelt: Zwei Schwerter sind zwei Plätze.

class Inventory
{
public readonly int Capacity;
public readonly OrderedDictionary<string, Item> Items;
public readonly List<Slot> Slots = new();
public Inventory(int capacity, OrderedDictionary<string, Item> items)
{
Capacity = capacity;
Items = items;
}
public int Count(string itemId)
{
int total = 0;
foreach (Slot slot in Slots)
{
if (slot.Id == itemId) total += slot.Count;
}
return total;
}
public bool CanAdd(string itemId, int count)
{
Item item = Items[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = Items[itemId];
if (item.Stackable)
{
foreach (Slot slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new Slot(itemId, count));
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1));
return true;
}
public void Remove(string itemId, int count)
{
int left = count;
var kept = new List<Slot>();
foreach (Slot slot in Slots)
{
if (slot.Id == itemId && left > 0)
{
int taken = Math.Min(left, slot.Count);
slot.Count -= taken;
left -= taken;
}
if (slot.Count > 0) kept.Add(slot);
}
Slots.Clear();
Slots.AddRange(kept);
}
public void RemoveAt(int index)
{
Slot slot = Slots[index];
slot.Count -= 1;
if (slot.Count <= 0) Slots.RemoveAt(index);
}
}

Alles wird in canAdd entschieden: Wenn der Gegenstand stapelbar ist und es schon einen gibt, ist kein Platz nötig; sonst werden so viele neue Plätze gebraucht, wie es Gegenstände gibt. add fragt zuerst canAdd, und wenn es nicht geht, tut es nichts (es legt keinen halben Stapel ab). Das Ergebnis ist true oder false, so weiß der Aufrufer, ob er „kein Platz“ sagen muss.

static string Label(string name, int count)
{
return count > 1 ? name + " x" + count : name;
}
static string FillText(Inventory bag)
{
return "(" + bag.Slots.Count + "/" + bag.Capacity + ")";
}
static void PrintBag(Inventory bag)
{
Console.WriteLine("Der Beutel:");
for (int i = 0; i < bag.Slots.Count; i++)
{
Slot slot = bag.Slots[i];
Console.WriteLine((i + 1) + ") " + Label(bag.Items[slot.Id].Name, slot.Count));
}
}
static void AddAndShow(Inventory bag, string itemId, int count)
{
bool added = bag.Add(itemId, count);
Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "erledigt" : "kein Platz") + " " + FillText(bag));
}
var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(4, items);
AddAndShow(bag, "potion_heal", 2);
AddAndShow(bag, "spear", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 1);
AddAndShow(bag, "leather", 1);
AddAndShow(bag, "chain", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "ore", 1);
PrintBag(bag);
bag.Remove("potion_heal", 3);
Console.WriteLine("- " + Label(items["potion_heal"].Name, 3) + ": " + FillText(bag));
bag.Remove("spear", 1);
Console.WriteLine("- " + items["spear"].Name + ": " + FillText(bag));
AddAndShow(bag, "chain", 1);
PrintBag(bag);
Ausgabe
+ Heiltrank x2: erledigt (1/4)
+ Speer: erledigt (2/4)
+ Heiltrank: erledigt (2/4)
+ Manatrank: erledigt (3/4)
+ Lederrüstung: erledigt (4/4)
+ Kettenhemd: kein Platz (4/4)
+ Heiltrank: erledigt (4/4)
+ Silbererz: kein Platz (4/4)
Der Beutel:
1) Heiltrank x4
2) Speer
3) Manatrank
4) Lederrüstung
- Heiltrank x3: (4/4)
- Speer: (3/4)
+ Kettenhemd: erledigt (4/4)
Der Beutel:
1) Heiltrank
2) Manatrank
3) Lederrüstung
4) Kettenhemd

Das Kettenhemd passt nicht in einen vollen Beutel (4 von 4), aber ein weiterer Heiltrank passt: Sein Stapel ist schon begonnen. Das Erz hat keinen Platz. Danach nimmt remove drei Tränke vom Stapel und den Speer weg, und das Kettenhemd passt auf den freien Platz. removeAt(i) entfernt einen Gegenstand von einem Platz (der Laden benutzt es zum Verkaufen) und gibt den Platz frei, wenn die Zahl auf null fällt.

Genauer, für Neugierige

Ein Stapel hält eine id und eine count, nicht den Gegenstand selbst. Die Definition eines Gegenstands (Name, Preis, Modifikatoren) gibt es einmal für die ganze Welt, in der Tabelle; der Beutel hält nur Verweise mit Anzahlen. Zwanzig Speere kopieren den Preis nicht zwanzigmal.

Die Idee

Beute mit Gewichten

Was lässt der Goblin fallen? Nicht immer dasselbe, und nicht mit gleicher Chance: oft nichts, manchmal einen Trank, selten einen Speer. Dafür hat jeder Gegner eine Beutetabelle: Zeilen mit einem Gewicht. Das Gewicht ist relativ: Eine Zeile mit Gewicht 30 in einer Tabelle mit Summe 100 fällt in 30 % der Fälle. Die Zeile none heißt „nichts“, und die Anzahl ist zufällig zwischen min und max.

# table|item|weight|min|max
goblin|none|40|0|0
goblin|potion_heal|30|1|1
goblin|potion_mana|10|1|1
goblin|spear|10|1|1
goblin|leather|10|1|1
golem|ore|100|1|2

Der Wurf ist kurz. Die Gewichte werden addiert (zusammen 100), es wird eine Zahl von 0 bis 99 gewürfelt, dann werden die Zeilen der Reihe nach durchgegangen, und von der Zahl wird das Gewicht jeder Zeile abgezogen, bis die Zahl kleiner ist als das Gewicht der aktuellen Zeile. Diese Zeile fällt. So ist die Breite einer Zeile genau ihre Chance, und man braucht weder eine Sortierung nach Gewichten noch Prozente, die sich zu 100 addieren.

record Entry(string ItemId, int Weight, int Min, int Max);
record Drop(string Id, int Count);
static Dictionary<string, List<Entry>> LoadLoot(string text)
{
var tables = new Dictionary<string, List<Entry>>();
foreach (string[] c in ParseRows(text, 5))
{
if (!tables.ContainsKey(c[0])) tables[c[0]] = new List<Entry>();
tables[c[0]].Add(new Entry(c[1], int.Parse(c[2]), int.Parse(c[3]), int.Parse(c[4])));
}
return tables;
}
static int TotalWeight(List<Entry> entries)
{
int total = 0;
foreach (Entry entry in entries) total += entry.Weight;
return total;
}
// Gewürfelt wird eine Zahl von 0 bis zum Gesamtgewicht minus 1; die Gewichte werden eins nach dem anderen abgezogen, bis die Zahl in eine Zeile fällt.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll -= entry.Weight;
}
return null;
}
// Gewürfelt wird eine Zahl von 0 bis zum Gesamtgewicht minus 1; die Gewichte werden eins nach dem anderen abgezogen, bis die Zahl in eine Zeile fällt.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
    int roll = rng.NextInt(TotalWeight(entries));
    foreach (Entry entry in entries)
    {
        if (roll < entry.Weight)
        {
            if (entry.ItemId == "none") return null;
            return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
        }
        roll  entry.Weight;
    }
    return null;
}

Das Programm besiegt sechs Gegner mit dem Startwert 6 und legt die Beute direkt in einen Beutel mit 3 Plätzen. Der Generator ist das Random des Kanons (derselbe mulberry32 wie im Kampf), deshalb ist die Ausgabe in allen vier Sprachen gleich:

var items = LoadItems(ITEMS_TEXT);
var tables = LoadLoot(LOOT_TEXT);
var rng = new Random(6);
var bag = new Inventory(3, items);
for (int i = 0; i < kills.Length; i++)
{
Drop? drop = RollLoot(rng, tables[kills[i]]);
string head = "Gegner " + (i + 1) + " (" + kills[i] + "): ";
if (drop == null)
{
Console.WriteLine(head + "nichts");
continue;
}
bool added = bag.Add(drop.Id, drop.Count);
Console.WriteLine(head + Label(items[drop.Id].Name, drop.Count) + " -> " + (added ? "erledigt" : "kein Platz") + " " + FillText(bag));
}
PrintBag(bag);
Ausgabe
Gegner 1 (goblin): nichts
Gegner 2 (goblin): Heiltrank -> erledigt (1/3)
Gegner 3 (goblin): Speer -> erledigt (2/3)
Gegner 4 (golem): Silbererz x2 -> erledigt (3/3)
Gegner 5 (goblin): Manatrank -> kein Platz (3/3)
Gegner 6 (goblin): Heiltrank -> erledigt (3/3)
Der Beutel:
1) Heiltrank x2
2) Speer
3) Silbererz x2

Der erste Goblin lässt nichts fallen. Der zweite lässt einen Trank fallen, der dritte einen Speer, der Golem zwei Stück Erz (der Beutel ist schon voll, 3 von 3). Der fünfte Goblin lässt einen Manatrank fallen, aber für einen neuen Stapel ist kein Platz: Im Spiel bliebe er auf dem Boden liegen, hier kommt er einfach nicht hinein. Der sechste lässt einen Heiltrank fallen: Es gibt schon einen im Beutel, und er wird gestapelt, obwohl der Beutel voll ist.

Zufällig ist nur die Zahl; die Gewichte bestimmen, wie oft. Prüfen wir es: dieselbe Tabelle, aber 1000 besiegte Goblins, und es wird gezählt, wie oft jede Zeile fiel.

var entries = LoadLoot(LOOT_TEXT)["goblin"];
var rng = new Random(2026);
var counts = new Dictionary<string, int>();
for (int i = 0; i < 1000; i++)
{
Drop? drop = RollLoot(rng, entries);
string key = drop == null ? "none" : drop.Id;
counts[key] = counts.GetValueOrDefault(key, 0) + 1;
}
int total = TotalWeight(entries);
foreach (Entry entry in entries)
{
Console.WriteLine(entry.ItemId + ": " + counts.GetValueOrDefault(entry.ItemId, 0) + " (erwartet " + entry.Weight * 1000 / total + ")");
}
Ausgabe
none: 396 (erwartet 400)
potion_heal: 300 (erwartet 300)
potion_mana: 117 (erwartet 100)
spear: 91 (erwartet 100)
leather: 96 (erwartet 100)

Die erwarteten Anzahlen sind die Gewichte × 10, und die Ergebnisse liegen nahe daran, sind aber selten genau gleich. Das ist Wahrscheinlichkeit, keine Verteilung nach Fahrplan. Bei weniger besiegten Gegnern ist der Unterschied größer (sechs Gegner sind für nichts genug), deshalb prüft man ein gutes Gleichgewicht mit tausenden besiegten Gegnern: Die letzte Lektion macht genau das mit dem ganzen Spiel.

Sieh es dir an

Der Wurf in Aktion

Hier sind dieselben sechs besiegten Gegner Zug um Zug. Der Balken ist die Tabelle: Je breiter eine Zeile, desto öfter fällt sie. Der Zeiger geht zur gewürfelten Zahl, unten ist das Abziehen der Gewichte zu sehen, und der Gegenstand verlässt seine Zeile und kommt in den Beutel: auf einen neuen Platz, auf den alten Platz (er stapelt sich) oder gar nicht, wenn der Beutel voll ist.

// Gewürfelt wird eine Zahl von 0 bis zum Gesamtgewicht minus 1; die Gewichte werden eins nach dem anderen abgezogen, bis die Zahl in eine Zeile fällt.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll -= entry.Weight;
}
return null;
}
public bool CanAdd(string itemId, int count)
{
Item item = Items[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = Items[itemId];
if (item.Stackable)
{
foreach (Slot slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new Slot(itemId, count));
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1));
return true;
}
Schritt für Schritt, in Worten
  1. Die Tabelle des Goblins ist Text: 5 Zeilen, jede mit einem Gewicht, zusammen 100. Das Gewicht ist die Breite der Zeile im Balken: Gewicht 30 von 100 sind 30 % Chance. Der Beutel hat 3 Plätze.
  2. Gegner 1 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 1. Die Gewichte werden eins nach dem anderen abgezogen: 1 < 40. Die Zahl fällt in die Zeile „nichts“ (Gewicht 40 von 100).
  3. „nichts“ ist eine Zeile wie jede andere (Gewicht 40): keine Beute, und der Beutel bleibt unberührt.
  4. Gegner 2 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 56. Die Gewichte werden eins nach dem anderen abgezogen: 56 ≥ 40 → 16 → 16 < 30. Die Zahl fällt in die Zeile „Heiltrank“ (Gewicht 30 von 100).
  5. Heiltrank kommt in den Beutel auf einen neuen Platz (1/3).
  6. Gegner 3 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 89. Die Gewichte werden eins nach dem anderen abgezogen: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. Die Zahl fällt in die Zeile „Speer“ (Gewicht 10 von 100).
  7. Speer kommt in den Beutel auf einen neuen Platz (2/3).
  8. Gegner 4 (golem): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 1. Die Gewichte werden eins nach dem anderen abgezogen: 1 < 100. Die Zahl fällt in die Zeile „Silbererz“ (Gewicht 100 von 100).
  9. Silbererz x2 kommt in den Beutel auf einen neuen Platz (3/3).
  10. Gegner 5 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 76. Die Gewichte werden eins nach dem anderen abgezogen: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. Die Zahl fällt in die Zeile „Manatrank“ (Gewicht 10 von 100).
  11. Der Beutel ist voll (3/3), und Manatrank hat nichts, womit es sich stapeln ließe: canAdd gibt false zurück und add tut nichts (im Spiel bleibt der Gegenstand auf dem Boden liegen).
  12. Gegner 6 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 56. Die Gewichte werden eins nach dem anderen abgezogen: 56 ≥ 40 → 16 → 16 < 30. Die Zahl fällt in die Zeile „Heiltrank“ (Gewicht 30 von 100).
  13. Heiltrank ist schon im Beutel, deshalb wird es auf demselben Platz gestapelt (Heiltrank x2); die belegten Plätze bleiben bei 3/3.
  14. Nach 6 besiegten Gegnern ist der Beutel voll. Zufällig ist nur die Zahl; die Gewichte bestimmen, wie oft jede Zeile fällt, und der Beutel entscheidet, was hineinpasst.

Der Code neben der Szene ist rollLoot, canAdd und add aus dem Programm. Bei jedem Schritt leuchten die Zeilen auf, die in diesem Moment arbeiten.

Die Idee

Der Laden: kaufen und verkaufen

Der Laden ist das Kürzeste der fünf Dinge, weil alles, was er braucht, schon gebaut ist. Kaufen sind drei Prüfungen der Reihe nach: Reicht das Gold, ist im Beutel Platz, dann wird bezahlt und der Gegenstand hinzugefügt. Verkaufen entfernt einen Gegenstand von einem Platz und zahlt den halben Preis, abgerundet.

// Ein Verkauf bringt den halben Preis, abgerundet.
static int SellPrice(Item item)
{
return item.Price / 2;
}
static string Buy(Player player, OrderedDictionary<string, Item> items, string itemId)
{
Item item = items[itemId];
string result = "erledigt";
if (player.Gold < item.Price) result = "zu wenig Gold";
else if (!player.Bag.CanAdd(itemId, 1)) result = "kein Platz";
else
{
player.Gold -= item.Price;
player.Bag.Add(itemId, 1);
}
return "kauft " + item.Name + " (" + item.Price + "): " + result + ", Gold " + player.Gold;
}
static string Sell(Player player, OrderedDictionary<string, Item> items, int slotNumber)
{
Slot slot = player.Bag.Slots[slotNumber - 1];
Item item = items[slot.Id];
int price = SellPrice(item);
player.Bag.RemoveAt(slotNumber - 1);
player.Gold += price;
return "verkauft " + item.Name + " (" + price + "): erledigt, Gold " + player.Gold;
}
var items = LoadItems(ITEMS_TEXT);
var player = new Player(100, new Inventory(3, items));
Console.WriteLine(Buy(player, items, "potion_heal"));
Console.WriteLine(Buy(player, items, "potion_heal"));
Console.WriteLine(Buy(player, items, "chain"));
Console.WriteLine(Buy(player, items, "leather"));
Console.WriteLine(Buy(player, items, "war_axe"));
Console.WriteLine(Sell(player, items, 2));
Console.WriteLine(Buy(player, items, "potion_mana"));
Console.WriteLine(Buy(player, items, "antidote"));
Console.WriteLine(Buy(player, items, "leather"));
Console.WriteLine(Sell(player, items, 1));
Ausgabe
kauft Heiltrank (15): erledigt, Gold 85
kauft Heiltrank (15): erledigt, Gold 70
kauft Kettenhemd (90): zu wenig Gold, Gold 70
kauft Lederrüstung (30): erledigt, Gold 40
kauft Streitaxt (80): zu wenig Gold, Gold 40
verkauft Lederrüstung (15): erledigt, Gold 55
kauft Manatrank (15): erledigt, Gold 40
kauft Gegengift (10): erledigt, Gold 30
kauft Lederrüstung (30): kein Platz, Gold 30
verkauft Heiltrank (7): erledigt, Gold 37

Die Reihenfolge der Prüfungen ist wichtig. Das Kettenhemd für 90 kann man nicht mit 70 Gold kaufen, die Streitaxt für 80 nicht mit 40. In der neunten Zeile hat der Held das Gold für eine Lederrüstung (30), aber der Beutel ist voll: Die Goldprüfung geht durch, canAdd lehnt ab, und es wird kein Gold genommen. Der Verkauf der Lederrüstung bringt 15 (30 / 2), der eines Heiltranks 7 (15 / 2, abgerundet): Dasselbe zu kaufen und sofort zu verkaufen ist nie ein Gewinn.

Hier ist der Verkaufspreis im Kanon. Das Kaufen sind dort dieselben Prüfungen, nur liest der Bildschirm die Nummer von der Tastatur:

public static int SellPrice(Item item)
{
return item.Price / 2;
}

Die Idee

Im Spiel

Was wir gebaut haben, ist dasselbe wie im Kanon, nur kleiner. Hier ist das echte Inventar:

class Inventory
{
public int Capacity;
public List<ItemStack> Slots;
public Inventory(int capacity)
{
Capacity = capacity;
Slots = new List<ItemStack>();
}
public int Count(string itemId)
{
int total = 0;
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId) total += slot.Count;
}
return total;
}
public bool CanAdd(string itemId, int count)
{
Item item = ITEMS[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = ITEMS[itemId];
if (item.Stackable)
{
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new ItemStack { Id = itemId, Count = count });
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new ItemStack { Id = itemId, Count = 1 });
return true;
}
public void Remove(string itemId, int count)
{
int left = count;
List<ItemStack> kept = new List<ItemStack>();
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId && left > 0)
{
int taken = Math.Min(left, slot.Count);
slot.Count -= taken;
left -= taken;
}
if (slot.Count > 0) kept.Add(slot);
}
Slots = kept;
}
public void RemoveAt(int index)
{
ItemStack slot = Slots[index];
slot.Count -= 1;
if (slot.Count <= 0) Slots.RemoveAt(index);
}
}

Die Ausrüstung im Kanon ist Teil von Creature: gearBonus addiert die Modifikatoren über die Plätze und sitzt zwischen der Basis und den Effekten (siehe die Lektion über Helden und Gegner):

public override int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Equipment.Keys)
{
string itemId = Equipment.Get(slot);
if (itemId == "") continue;
foreach (Mod mod in ITEMS[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public Item Weapon()
{
string id = Equipment.Get("weapon");
return ITEMS[id == "" ? "fists" : id];
}

Das Anlegen eines Gegenstands aus dem Beutel setzt den alten an dieselbe Stelle, damit die Reihenfolge der Gegenstände nicht durcheinandergerät:

// Legt den Gegenstand aus dem Inventar an; der alte kommt an seinen Platz im Inventar.
static partial class Arcanum
{
public static string EquipFromInventory(Hero hero, int index)
{
ItemStack slot = hero.Inventory.Slots[index];
Item item = ITEMS[slot.Id];
if (item.Kind != "weapon" && item.Kind != "armor" && item.Kind != "ring") return "";
string old = hero.Equipment.Get(item.Kind);
hero.Inventory.RemoveAt(index);
hero.Equipment.Set(item.Kind, item.Id);
if (old != "") hero.Inventory.Slots.Insert(index, new ItemStack { Id = old, Count = 1 });
if (hero.Hp > hero.MaxHp()) hero.Hp = hero.MaxHp();
if (hero.Mana > hero.MaxMana()) hero.Mana = hero.MaxMana();
return item.Id;
}
}

Und der Beutewurf, den du kennst:

// Gewichtete Beute: Gewicht 30 von insgesamt 100 bedeutet 30 % Chance.
static partial class Arcanum
{
public static ItemStack? RollLoot(Random rng, string tableId)
{
List<LootEntry> entries = LOOT_TABLES[tableId];
int total = 0;
foreach (LootEntry entry in entries) total += entry.Weight;
int roll = rng.NextInt(total);
foreach (LootEntry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new ItemStack { Id = entry.ItemId, Count = rng.Range(entry.Min, entry.Max) };
}
roll -= entry.Weight;
}
return null;
}
}

Die Unterschiede zu unserem Programm:

  • Eine Tabelle pro Art. Waffen, Rüstungen, Ringe, Tränke und der Rest haben jeweils eigene Spalten; unsere Tabelle ist eine einzige, damit sie sich leicht liest.
  • Namen in vier Sprachen. Jede Zeile im Kanon trägt den Namen in bg, en, de und es; nm(item) wählt nach der Sprache des Spiels. Hier ist der Name eine Zelle.
  • Ein globales ITEMS. Der Kanon hält die Gegenstände in einem globalen Dictionary und sucht sie nach id. Wir geben sie an Inventory und Hero weiter, damit die Abhängigkeiten sichtbar sind und das Testen leicht ist.
  • Ein Beutel mit 10 Plätzen. Der Held im Kanon hat Inventory(10); hier sind es 3 und 4 Plätze, damit man sieht, wann sie sich füllen.
  • Gold und Tränke. Gold ist kein Gegenstand im Beutel, sondern eine Zahl beim Helden (Gegner lassen Gold getrennt von der Beute fallen). Tränke haben eine Aktion (heal, mana, cure), und drinkPotion trinkt sie im Kampf und auf der Karte; hier stapeln wir sie nur.
  • Beute fällt auf den Boden. Ein besiegter Gegner lässt den Gegenstand auf dem Boden liegen (dropItem), und der Held hebt ihn mit g auf; bei vollem Beutel bleibt der Gegenstand liegen, und der Spieler sieht „Der Beutel ist voll“. In unserem Programm geht die Beute direkt in den Beutel, und ein voller Beutel gibt false zurück.

Probier es aus

Sag die Ausgabe voraus

Drei Zeilen mit den Gewichten 50, 30 und 20 und sechs gewürfelte Zahlen. Was gibt das Programm aus?

static string Pick(string[] ids, int[] weights, int roll)
{
for (int i = 0; i < ids.Length; i++)
{
if (roll < weights[i]) return ids[i];
roll -= weights[i];
}
return ids[ids.Length - 1];
}
string[] ids = ["none", "potion_heal", "spear"];
int[] weights = [50, 30, 20];
int[] rolls = [0, 49, 50, 79, 80, 99];
foreach (int roll in rolls)
{
Console.WriteLine(roll + ": " + Pick(ids, weights, roll));
}
Antwort

Probier es aus

Finde den Fehler

Ein Kollege hat die Prüfung in canAdd umgeschrieben, und jetzt fasst ein Beutel mit 2 Plätzen drei Gegenstände, und ein Trank passt nicht in einen vollen Beutel:

var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(2, items);
AddAndShow(bag, "spear", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 1);
AddAndShow(bag, "potion_heal", 1);
Ausgabe
+ Speer: erledigt (1/2)
+ Heiltrank: erledigt (2/2)
+ Manatrank: erledigt (3/2)
+ Heiltrank: kein Platz (3/2)

Welche Zeile ist kaputt?

Wähle die Zeile mit dem Fehler.

Genauer

Genauer: Wahrscheinlichkeiten, Anteile und Schwachstellen

Gewichte, keine Prozente. Gewichte müssen sich nicht zu 100 addieren. Eine Tabelle 3, 1, 1 ist dasselbe wie 60, 20, 20. Die Summe ist der Teiler und wird bei jedem Wurf neu berechnet, deshalb kann eine neue Zeile hinzukommen, ohne den Rest anzufassen. Prozente müssten sich nach jeder Änderung zu 100 addieren, Gewichte nicht.

Abziehen oder addieren. Man kann eine Zahl würfeln und mit Summen (40, 70, 80, 90, 100) vergleichen: Zeile i fällt, wenn die Zahl unter der i-ten Summe liegt. Abziehen ist dasselbe ohne ein zweites Array. Bei großen Tabellen werden die Summen fertig gehalten und binär gesucht; für die Tabellen eines Spiels ist das unnötig.

„Nichts“ ist eine Zeile. none mit Gewicht 40 ist eine Zeile wie jede andere: Mit einer Zahl liefert eine Tabelle 0 % oder 90 % „nichts“. Und die Tabelle des Anführers (leader) im Kanon hat nur eine Zeile mit Gewicht 100: Der Ring fällt immer.

Alles oder nichts. Jede Änderung am Beutel läuft über add, das zuerst canAdd fragt. So können „ein halber Stapel“ oder „drei Gegenstände in zwei Plätzen“ nicht entstehen. Die Übung unten schützt genau das.

Runden. Preis / 2 mit ganzzahliger Division schneidet den Rest ab, deshalb zahlt ein Verkauf nie mehr als die Hälfte. In Python und JavaScript ist die ganzzahlige Division // und Math.floor; sie arbeiten richtig, weil Preise nicht negativ sind.

Zufall und Wiederholbarkeit. Der Wurf weiß nicht, was fallen wird; er weiß nur, wie er auf eine Zahl antwortet. Der Generator hat einen Startwert (mulberry32), deshalb ist die Beute mit demselben Startwert und denselben Besiegten in allen vier Sprachen und in einem aufgezeichneten Durchlauf gleich. Das brauchen wir auch für das Speichern und die Simulation.

Übung

Stapel mit einer Obergrenze

Löse die Aufgabe in deinem Editor. Geh vom Inventarprogramm aus.

Im Spiel sind zehn Tränke auf einem Platz zu viel. Füge eine Obergrenze für einen Stapel hinzu: höchstens 5 von einem Gegenstand pro Platz. Was sich ändert:

  • ein Stapel, der 5 erreicht, ist voll; mehr vom selben kommt auf einen neuen Platz;
  • add muss alles oder nichts sein: Passen nicht alle hinein (in alte Stapel und in neue Plätze), wird nichts abgelegt;
  • nicht stapelbare Gegenstände belegen wie bisher einen Platz pro Stück.

Gebraucht werden room (wie viel noch in alte Stapel passt), ein korrigiertes canAdd und ein add, das zuerst die alten Stapel auffüllt und dann neue macht. Der Beutel hat 3 Plätze, und das Szenario ist dieses:

static void AddAndShow(Inventory bag, string itemId, int count)
{
bool added = bag.Add(itemId, count);
var parts = new List<string>();
foreach (Slot slot in bag.Slots) parts.Add(Label(bag.Items[slot.Id].Name, slot.Count));
Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "erledigt" : "kein Platz") + " " + FillText(bag) + " [" + string.Join(", ", parts) + "]");
}
var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(3, items);
AddAndShow(bag, "potion_heal", 4);
AddAndShow(bag, "potion_heal", 3);
AddAndShow(bag, "potion_mana", 2);
AddAndShow(bag, "potion_heal", 3);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 4);

Die Ausgabe ist:

Ausgabe
+ Heiltrank x4: erledigt (1/3) [Heiltrank x4]
+ Heiltrank x3: erledigt (2/3) [Heiltrank x5, Heiltrank x2]
+ Manatrank x2: erledigt (3/3) [Heiltrank x5, Heiltrank x2, Manatrank x2]
+ Heiltrank x3: erledigt (3/3) [Heiltrank x5, Heiltrank x5, Manatrank x2]
+ Heiltrank: kein Platz (3/3) [Heiltrank x5, Heiltrank x5, Manatrank x2]
+ Manatrank x4: kein Platz (3/3) [Heiltrank x5, Heiltrank x5, Manatrank x2]

Die Zeilen zeigen auch die Grenzen: Der zweite Schritt füllt den alten Stapel auf 5 auf und öffnet einen neuen Platz für die übrigen 2; der vierte füllt den schon begonnenen Stapel auf und braucht keinen neuen Platz; der letzte (noch 4 Manatränke bei einem Stapel von 2) legt nichts ab, obwohl 3 davon passen würden, weil der vierte nirgends hin kann.

Weitere Aufgaben

  • Verkaufe den Plunder. Füge sellJunk(player, items) hinzu: Es verkauft alles der Art misc und gibt zurück, wie viel Gold eingenommen wurde.
  • Die beste Waffe. Wenn der Held eine Waffe aufhebt, soll er sie nur anlegen, wenn die Summe ihrer Modifikatoren größer ist als die der gerade angelegten.
  • Glück. Gib rollLoot einen Parameter luck: Je größer er ist, desto mehr wird das Gewicht der Zeile none verringert (aber nicht unter 0), und die anderen bleiben. Was passiert mit den Chancen der anderen?

Test

Prüfe dich

1. Warum sind die Gegenstände des Spiels eine Tabelle in Text und nicht new Item(...) im Code?
2. Der Held hat 40 Leben mit einem Ring des Lebens (+10 maximales Leben). Das Leben ist voll: 40/40. Er nimmt den Ring ab. Wie viel Leben hat er danach?
3. Der Beutel hat 4 Plätze, und alle sind belegt. Es ist ein Heiltrank darin. Kann noch ein solcher Trank hinzugefügt werden?
4. Die Tabelle des Goblins ist: nichts 40, Heiltrank 30, Manatrank 10, Speer 10, Lederrüstung 10. Gewürfelt wird 85. Welcher Gegenstand fällt?
5. Ein Heiltrank kostet 15. Für wie viel verkauft der Held ihn im Laden?
6. Bei 1000 besiegten Goblins zählt das Programm 396-mal „nichts“ und nicht genau 400. Warum?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·