Items, inventory, loot
In short: Items are a table in text read into objects; equipment adds modifiers to the stats, the inventory has slots and stacks potions, loot is rolled by weights, and the shop buys and sells.
Problem
A new sword, without touching the code
In the mine the hero finds a spear. The trader sells chain mail. The goblin drops a potion. If every item is a line of code, new Item("spear", "spear", ...), then every new sword is a new version of the program, and a game designer who wants to change the price of the chain mail from 90 to 80 has to wait for a compilation. The game has twenty-two items, and there will be more.
That is why data is separated from code. The items are a table in text: one row is one item, the cells are separated by |, and rows starting with # are comments.
# id|name|kind|price|modifiersspear|spear|weapon|20|accuracy:5war_axe|war axe|weapon|80|accuracy:-5leather|leather armor|armor|30|defense:2chain|chain mail|armor|90|defense:4,evasion:-5ring_life|ring of life|ring|70|maxHp:10ring_agility|ring of agility|ring|70|agility:2potion_heal|healing potion|potion|15|-potion_mana|mana potion|potion|15|-antidote|antidote|potion|10|-ore|silver ore|misc|8|-The lesson is about five things around this table: how the text becomes objects; how equipment (weapon, armor, ring) adds up into the stats; how the inventory with limited slots stacks identical potions; how loot is rolled from a table with weights; and how the shop buys and sells for gold. The small table above has ten items; the canon has twenty-two, but the code is the same.
The idea
A table in text, read into objects
The reader is three small functions. parseRows splits the text into lines, skips empty ones and comments, and splits the cells by |; if the number of cells is not what is expected, it stops at once and says which row is broken. parseMods reads the modifiers (defense:4,evasion:-5, and - means "none"). loadItems turns every row into an item.
record Mod(String stat, int value) {}
class Item { final String id; final String name; final String kind; final int price; final List<Mod> mods; final boolean stackable;
Item(String id, String name, String kind, int price, List<Mod> mods) { this.id = id; this.name = name; this.kind = kind; this.price = price; this.mods = mods; this.stackable = kind.equals("potion") || kind.equals("misc"); }}
// One row of the table is one item: id|name|kind|price|modifiers.static List<String[]> parseRows(String text, int columns) { List<String[]> rows = new ArrayList<>(); for (String raw : text.split("\n")) { String line = raw.trim(); if (line.isEmpty() || line.charAt(0) == '#') continue; String[] cells = line.split("\\|"); if (cells.length != columns) { throw new RuntimeException("expected " + columns + " cells in the row: " + line); } rows.add(cells); } return rows;}
// "-" means "no modifiers"; otherwise "stat:value" pairs separated by commas.static List<Mod> parseMods(String text) { List<Mod> mods = new ArrayList<>(); if (text.equals("-")) return mods; for (String part : text.split(",")) { String[] pieces = part.split(":"); mods.add(new Mod(pieces[0], Integer.parseInt(pieces[1]))); } return mods;}
static Map<String, Item> loadItems(String text) { Map<String, Item> items = new LinkedHashMap<>(); for (String[] c : parseRows(text, 5)) { items.put(c[0], new Item(c[0], c[1], c[2], Integer.parseInt(c[3]), parseMods(c[4]))); } return items;}Map<String, Item> items = loadItems(ITEMS_TEXT);for (Item item : items.values()) { System.out.println(item.name + " (" + item.kind + ") " + item.price + " gold: " + modsText(item.mods));}System.out.println("total: " + items.size());try { loadItems(BAD_ROW);} catch (RuntimeException e) { System.out.println(e.getMessage());}spear (weapon) 20 gold: accuracy +5war axe (weapon) 80 gold: accuracy -5leather armor (armor) 30 gold: defense +2chain mail (armor) 90 gold: defense +4, evasion -5ring of life (ring) 70 gold: maxHp +10ring of agility (ring) 70 gold: agility +2healing potion (potion) 15 gold: -mana potion (potion) 15 gold: -antidote (potion) 10 gold: -silver ore (misc) 8 gold: -total: 10expected 5 cells in the row: ring_life|ring of life|ring|70The program prints the items in the order of the table and at the end passes in a row with a missing cell (the price): the reader stops it with an exact message. A broken table must stop the game at start-up, not in the middle of a fight, when the player first steps on the item.
Map holds the items, and LinkedHashMap also keeps their insertion order: "all items" always come out in the order of the table. split("\\|") is a regular expression, so the bar is escaped with a backslash. The text of the table is a text block (""").Here is the reader in the canon. There every table has its own columns (weapons are 10 cells, armor 7), so parseRows takes their number and the name of the table for the message:
static List<List<String>> parseRows(String text, int columns, String tableName) { List<List<String>> rows = new ArrayList<>(); for (String rawLine : split(text, "\n")) { String line = trimSpaces(rawLine); if (line.length() == 0 || line.charAt(0) == '#') continue; List<String> cells = split(line, "|"); if (cells.size() != columns) { throw new RuntimeException(tableName + ": expected " + columns + " cells in: " + line); } rows.add(cells); } return rows;}
static List<Mod> parseMods(String text) { List<Mod> mods = new ArrayList<>(); for (String part : splitList(text)) { List<String> pieces = split(part, ":"); mods.add(new Mod(pieces.get(0), toInt(pieces.get(1)))); } return mods;}More precisely, if you are curious
Tables differ in their columns, so the canon has one short reader per kind (loadItems reads the five tables one after another), but one thing is shared: parseRows. A new kind of data (enemies, skills, dialogs) is a new table and a new short reader, not a new way of reading.
And here is the data itself from the game: the names in four languages, then the price and the modifiers.
// The data are tables in text: one line is one record, cells are separated by "|". Lines with "#" // in front are notes. The code reads them into objects at start; a new sword needs a new row, not code. // Modifiers are "stat:value", separated by commas; "-" means "nothing". static final String WEAPONS_DATA = """# id|name|damage type|min|max|price|modifiersfists|fists|blunt|1|2|0|-hand_axe|hand axe|slash|4|7|20|-spear|spear|pierce|3|6|20|accuracy:5staff|staff|blunt|2|4|10|maxMana:4war_axe|war axe|slash|6|10|80|accuracy:-5longbow|longbow|pierce|5|8|80|accuracy:10mace|mace|blunt|5|8|60|-rune_staff|rune staff|blunt|3|6|75|maxMana:10silver_sword|silver sword|slash|7|11|120|accuracy:5""";
static final String ARMORS_DATA = """# id|name|price|modifiersrobe|robe|5|maxMana:2leather|leather armor|30|defense:2chain|chain mail|90|defense:4,evasion:-5scale|scale armor|110|defense:5""";
static final String RINGS_DATA = """# id|name|price|modifiersring_strength|ring of strength|70|strength:2ring_agility|ring of agility|70|agility:2ring_life|ring of life|70|maxHp:10ring_mana|ring of mana|70|maxMana:8""";
static final String POTIONS_DATA = """# id|name|price|action|powerpotion_heal|healing potion|15|heal|20potion_mana|mana potion|15|mana|15antidote|antidote|10|cure|0""";
static final String MISC_DATA = """# id|name|priceore|silver ore|8gold|gold|0""";
// Loot: weighted rows for each table. "none" is "nothing"; the count is from min to max. static final String LOOT_DATA = """# table|item|weight|min|maxvermin|none|60|0|0vermin|potion_heal|25|1|1vermin|antidote|15|1|1goblin|none|40|0|0goblin|potion_heal|30|1|1goblin|potion_mana|10|1|1goblin|spear|10|1|1goblin|leather|10|1|1beast|none|70|0|0beast|potion_heal|30|1|1leader|ring_agility|100|1|1bones|none|50|0|0bones|mace|20|1|1bones|potion_heal|30|1|1cult|potion_mana|40|1|1cult|potion_heal|40|1|1cult|rune_staff|20|1|1golem|ore|100|1|2warden|none|100|0|0""";
static final String SHOP_DATA = """# what the shop sells, in this orderpotion_healpotion_manaantidoteleatherchainwar_axelongbowmacerune_staffring_lifering_mana""";
// The XP needed for each level: XP_TABLE[level] is the total XP that reaches that level. static final String XP_DATA = "0|0|20|55|105|170|250|345|455|580|720";The idea
Equipment: the base plus the items
The hero has three slots: weapon, armor, ring. A slot holds the id of an item or an empty string. A stat is not stored ready-made: it is calculated every time as the base (the hero’s number) plus the sum of the modifiers of everything that is equipped. So taking a ring off does not need "restore the old number", it simply calculates again.
class Hero { static final String[] SLOTS = { "weapon", "armor", "ring" };
final String name; final Map<String, Item> items; final Map<String, Integer> baseStats = new HashMap<>(); final Map<String, String> equipment = new HashMap<>(); int hp;
Hero(String name, Map<String, Item> items) { this.name = name; this.items = items; for (String slot : SLOTS) equipment.put(slot, ""); }
// The base plus the bonus from everything that is equipped. int gearBonus(String stat) { int total = 0; for (String slot : SLOTS) { String itemId = equipment.get(slot); if (itemId.isEmpty()) continue; for (Mod mod : items.get(itemId).mods) { if (mod.stat().equals(stat)) total += mod.value(); } } return total; }
int stat(String stat) { return baseStats.getOrDefault(stat, 0) + gearBonus(stat); }
// Life cannot stay above the new maximum. void clampHp() { if (hp > stat("maxHp")) hp = stat("maxHp"); }
String equip(String itemId) { Item item = items.get(itemId); String old = equipment.get(item.kind); equipment.put(item.kind, itemId); clampHp(); return old; }
void unequip(String slot) { equipment.put(slot, ""); clampHp(); }}equip puts the item in the slot of its kind (item.kind) and returns the old one, so it can go back to the bag. There is one unexpected moment: if the ring gave +10 maximum life and you take it off, the current life can stay above the new maximum. clampHp brings it back down.
Map<String, Item> items = loadItems(ITEMS_TEXT);Hero hero = new Hero("Iva", items);hero.baseStats.put("maxHp", 30);hero.baseStats.put("defense", 1);hero.baseStats.put("accuracy", 70);hero.hp = hero.stat("maxHp");System.out.println(hero.name + ": " + stats(hero));
for (String itemId : new String[] { "spear", "leather", "ring_life" }) { hero.equip(itemId); System.out.println("+ " + items.get(itemId).name + ": " + stats(hero));}hero.hp = hero.stat("maxHp");System.out.println("heal: " + stats(hero));
hero.equip("war_axe");System.out.println("+ " + items.get("war_axe").name + ": " + stats(hero));hero.unequip("ring");System.out.println("- " + items.get("ring_life").name + ": " + stats(hero));Iva: hp 30/30, defense 1, accuracy 70+ spear: hp 30/30, defense 1, accuracy 75+ leather armor: hp 30/30, defense 3, accuracy 75+ ring of life: hp 30/40, defense 3, accuracy 75heal: hp 40/40, defense 3, accuracy 75+ war axe: hp 40/40, defense 3, accuracy 65- ring of life: hp 30/30, defense 3, accuracy 65The lines follow the hero: the spear adds 5 to accuracy, the leather armor 2 to defense, the ring 10 to maximum life (but the current life is still 30/40: the maximum grows, the life does not). After the healing the war axe takes the place of the spear and accuracy drops from 75 to 65 (−5 instead of +5). And taking the ring off brings the maximum life back to 30, and clampHp lowers the current life to 30 as well.
The idea
Inventory: slots and stacks
The bag has a limited number of slots (10 in the game). An item takes a slot. But ten healing potions are not ten slots: identical potions and ore stack in one. In the table these are the kinds potion and misc (stackable). Weapons and armor do not stack: two swords are two slots.
class Inventory { final int capacity; final Map<String, Item> items; final List<Slot> slots = new ArrayList<>();
Inventory(int capacity, Map<String, Item> items) { this.capacity = capacity; this.items = items; }
int count(String itemId) { int total = 0; for (Slot slot : slots) { if (slot.id.equals(itemId)) total += slot.count; } return total; }
boolean canAdd(String itemId, int count) { Item item = items.get(itemId); if (item.stackable && count(itemId) > 0) return true; int needed = item.stackable ? 1 : count; return slots.size() + needed <= capacity; }
boolean add(String itemId, int count) { if (!canAdd(itemId, count)) return false; Item item = items.get(itemId); if (item.stackable) { for (Slot slot : slots) { if (slot.id.equals(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; }
void remove(String itemId, int count) { int left = count; List<Slot> kept = new ArrayList<>(); for (Slot slot : slots) { if (slot.id.equals(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.addAll(kept); }
void removeAt(int index) { Slot slot = slots.get(index); slot.count -= 1; if (slot.count <= 0) slots.remove(index); }}It is all decided in canAdd: if the item stacks and there is already one, no slot is needed; otherwise as many new slots are needed as there are items. add asks canAdd first, and if it is not possible it does nothing (it does not put half a stack). The result is true or false, so the caller knows whether to say "no room".
static String label(String name, int count) { return count > 1 ? name + " x" + count : name;}
static String fillText(Inventory bag) { return "(" + bag.slots.size() + "/" + bag.capacity + ")";}
static void printBag(Inventory bag) { System.out.println("The bag:"); for (int i = 0; i < bag.slots.size(); i++) { Slot slot = bag.slots.get(i); System.out.println((i + 1) + ") " + label(bag.items.get(slot.id).name, slot.count)); }}
static void addAndShow(Inventory bag, String itemId, int count) { boolean added = bag.add(itemId, count); System.out.println("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "done" : "no room") + " " + fillText(bag));}Map<String, Item> items = loadItems(ITEMS_TEXT);Inventory 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);System.out.println("- " + label(items.get("potion_heal").name, 3) + ": " + fillText(bag));bag.remove("spear", 1);System.out.println("- " + items.get("spear").name + ": " + fillText(bag));addAndShow(bag, "chain", 1);printBag(bag);+ healing potion x2: done (1/4)+ spear: done (2/4)+ healing potion: done (2/4)+ mana potion: done (3/4)+ leather armor: done (4/4)+ chain mail: no room (4/4)+ healing potion: done (4/4)+ silver ore: no room (4/4)The bag:1) healing potion x42) spear3) mana potion4) leather armor- healing potion x3: (4/4)- spear: (3/4)+ chain mail: done (4/4)The bag:1) healing potion2) mana potion3) leather armor4) chain mailThe chain mail does not fit into a full bag (4 of 4), but one more healing potion does: its stack is already started. The ore has nowhere to go. After that remove takes three potions off the stack and the spear away, and the chain mail fits into the freed slot. removeAt(i) removes one item from a slot (the shop uses it for selling) and frees the slot when the count drops to zero.
More precisely, if you are curious
A stack holds an id and a count, not the item itself. The definition of an item (name, price, modifiers) is one for the whole world, in the table; the bag only holds references with counts. Twenty spears do not copy the price twenty times.
The idea
Loot with weights
What does the goblin drop? Not always the same thing, and not with equal chances: often nothing, sometimes a potion, rarely a spear. For this every enemy has a loot table: rows with a weight. The weight is relative: a row with weight 30 in a table with a total of 100 comes up in 30% of the cases. The row none means "nothing", and the count is random between min and max.
# table|item|weight|min|maxgoblin|none|40|0|0goblin|potion_heal|30|1|1goblin|potion_mana|10|1|1goblin|spear|10|1|1goblin|leather|10|1|1golem|ore|100|1|2The roll is short. The weights are added up (100 in total), a number from 0 to 99 is rolled, and then the rows are walked in order and the weight of each is subtracted from the number until the number is smaller than the weight of the current row. That row drops. So the width of a row is exactly its chance, and you need neither an order by weights nor percentages that add up to 100.
record Entry(String itemId, int weight, int min, int max) {}
record Drop(String id, int count) {}
static Map<String, List<Entry>> loadLoot(String text) { Map<String, List<Entry>> tables = new LinkedHashMap<>(); for (String[] c : parseRows(text, 5)) { if (!tables.containsKey(c[0])) tables.put(c[0], new ArrayList<>()); tables.get(c[0]).add(new Entry(c[1], Integer.parseInt(c[2]), Integer.parseInt(c[3]), Integer.parseInt(c[4]))); } return tables;}
static int totalWeight(List<Entry> entries) { int total = 0; for (Entry entry : entries) total += entry.weight(); return total;}
// A number from 0 to the total weight minus 1 is rolled; the weights are subtracted one by one until the number falls inside a row.static Drop rollLoot(Random rng, List<Entry> entries) { int roll = rng.nextInt(totalWeight(entries)); for (Entry entry : entries) { if (roll < entry.weight()) { if (entry.itemId().equals("none")) return null; return new Drop(entry.itemId(), rng.range(entry.min(), entry.max())); } roll -= entry.weight(); } return null;}// A number from 0 to the total weight minus 1 is rolled; the weights are subtracted one by one until the number falls inside a row.
static Drop rollLoot(Random rng, List<Entry> entries) {
int roll = rng.nextInt(totalWeight(entries));
for (Entry entry : entries) {
if (roll < entry.weight()) {
if (entry.itemId().equals("none")) return null;
return new Drop(entry.itemId(), rng.range(entry.min(), entry.max()));
}
roll entry.weight();
}
return null;
}The weights are subtracted: that way in each following row the number is measured from the start of that row. Without the subtraction each row would be compared with the unshifted number, and the early rows would come up too often. The operator is -=.
The program defeats six enemies with seed 6 and puts the loot straight into a bag with 3 slots. The generator is the Random of the canon (the same mulberry32 as in the battle), so the output is the same in all four languages:
Map<String, Item> items = loadItems(ITEMS_TEXT);Map<String, List<Entry>> tables = loadLoot(LOOT_TEXT);Random rng = new Random(6);Inventory bag = new Inventory(3, items);for (int i = 0; i < KILLS.length; i++) { Drop drop = rollLoot(rng, tables.get(KILLS[i])); String head = "enemy " + (i + 1) + " (" + KILLS[i] + "): "; if (drop == null) { System.out.println(head + "nothing"); continue; } boolean added = bag.add(drop.id(), drop.count()); System.out.println(head + label(items.get(drop.id()).name, drop.count()) + " -> " + (added ? "done" : "no room") + " " + fillText(bag));}printBag(bag);enemy 1 (goblin): nothingenemy 2 (goblin): healing potion -> done (1/3)enemy 3 (goblin): spear -> done (2/3)enemy 4 (golem): silver ore x2 -> done (3/3)enemy 5 (goblin): mana potion -> no room (3/3)enemy 6 (goblin): healing potion -> done (3/3)The bag:1) healing potion x22) spear3) silver ore x2The first goblin drops nothing. The second drops a potion, the third a spear, the golem two pieces of ore (the bag is already full, 3 of 3). The fifth goblin drops a mana potion, but there is no room for a new stack: in the game it would stay on the floor, here it simply does not go in. The sixth drops a healing potion: there is one in the bag already and it stacks, although the bag is full.
Only the number is random; the weights decide how often. Let us check: the same table, but 1000 defeated goblins, counting how many times each row came up.
List<Entry> entries = loadLoot(LOOT_TEXT).get("goblin");Random rng = new Random(2026);Map<String, Integer> counts = new HashMap<>();for (int i = 0; i < 1000; i++) { Drop drop = rollLoot(rng, entries); String key = drop == null ? "none" : drop.id(); counts.put(key, counts.getOrDefault(key, 0) + 1);}int total = totalWeight(entries);for (Entry entry : entries) { System.out.println(entry.itemId() + ": " + counts.getOrDefault(entry.itemId(), 0) + " (expected " + entry.weight() * 1000 / total + ")");}none: 396 (expected 400)potion_heal: 300 (expected 300)potion_mana: 117 (expected 100)spear: 91 (expected 100)leather: 96 (expected 100)The expected counts are the weights × 10, and the results are close to them, but rarely exactly equal. This is probability, not a distribution by schedule. With fewer defeated enemies the difference is bigger (six enemies are far too few for anything), so a good balance is checked with thousands of defeated enemies: the last lesson does exactly that with the whole game.
See it
The roll in action
Here are the same six defeated enemies turn by turn. The bar is the table: the wider a row, the more often it comes up. The pointer goes to the rolled number, the subtraction of the weights is shown below, and the item leaves its row and enters the bag: in a new slot, in the old slot (it stacks) or not at all if the bag is full.
// A number from 0 to the total weight minus 1 is rolled; the weights are subtracted one by one until the number falls inside a row.static Drop rollLoot(Random rng, List<Entry> entries) { int roll = rng.nextInt(totalWeight(entries)); for (Entry entry : entries) { if (roll < entry.weight()) { if (entry.itemId().equals("none")) return null; return new Drop(entry.itemId(), rng.range(entry.min(), entry.max())); } roll -= entry.weight(); } return null;}
boolean canAdd(String itemId, int count) { Item item = items.get(itemId); if (item.stackable && count(itemId) > 0) return true; int needed = item.stackable ? 1 : count; return slots.size() + needed <= capacity;}
boolean add(String itemId, int count) { if (!canAdd(itemId, count)) return false; Item item = items.get(itemId); if (item.stackable) { for (Slot slot : slots) { if (slot.id.equals(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;}Step by step, in words
- The goblin’s table is text: 5 rows, each with a weight, 100 in total. The weight is the width of the row in the bar: weight 30 of 100 is a 30% chance. The bag has 3 slots.
- Enemy 1 (goblin): a number from 0 to 99 is rolled and gives 1. The weights are subtracted one by one: 1 < 40. The number falls in the row “nothing” (weight 40 of 100).
- “nothing” is a row like any other (weight 40): no loot, and the bag is not touched.
- Enemy 2 (goblin): a number from 0 to 99 is rolled and gives 56. The weights are subtracted one by one: 56 ≥ 40 → 16 → 16 < 30. The number falls in the row “healing potion” (weight 30 of 100).
- Healing potion goes into the bag in a new slot (1/3).
- Enemy 3 (goblin): a number from 0 to 99 is rolled and gives 89. The weights are subtracted one by one: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. The number falls in the row “spear” (weight 10 of 100).
- Spear goes into the bag in a new slot (2/3).
- Enemy 4 (golem): a number from 0 to 99 is rolled and gives 1. The weights are subtracted one by one: 1 < 100. The number falls in the row “silver ore” (weight 100 of 100).
- Silver ore x2 goes into the bag in a new slot (3/3).
- Enemy 5 (goblin): a number from 0 to 99 is rolled and gives 76. The weights are subtracted one by one: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. The number falls in the row “mana potion” (weight 10 of 100).
- The bag is full (3/3) and mana potion has nothing to stack with: canAdd returns false and add does nothing (in the game the item stays on the floor).
- Enemy 6 (goblin): a number from 0 to 99 is rolled and gives 56. The weights are subtracted one by one: 56 ≥ 40 → 16 → 16 < 30. The number falls in the row “healing potion” (weight 30 of 100).
- Healing potion is already in the bag, so it stacks in the same slot (healing potion x2); the used slots stay at 3/3.
- After 6 defeated enemies the bag is full. Only the number is random; the weights say how often each row comes up, and the bag decides what fits.
The code next to the scene is rollLoot, canAdd and add from the program. At each step the lines that are working at that moment light up.
The idea
The shop: buying and selling
The shop is the shortest of the five things, because everything it needs is already built. Buying is three checks in order: is there enough gold, is there room in the bag, then pay and add the item. Selling removes one item from a slot and pays half the price, rounded down.
// A sale pays half the price, rounded down.static int sellPrice(Item item) { return item.price / 2;}
static String buy(Player player, Map<String, Item> items, String itemId) { Item item = items.get(itemId); String result = "done"; if (player.gold < item.price) result = "not enough gold"; else if (!player.bag.canAdd(itemId, 1)) result = "no room"; else { player.gold -= item.price; player.bag.add(itemId, 1); } return "buys " + item.name + " (" + item.price + "): " + result + ", gold " + player.gold;}
static String sell(Player player, Map<String, Item> items, int slotNumber) { Slot slot = player.bag.slots.get(slotNumber - 1); Item item = items.get(slot.id); int price = sellPrice(item); player.bag.removeAt(slotNumber - 1); player.gold += price; return "sells " + item.name + " (" + price + "): done, gold " + player.gold;}Map<String, Item> items = loadItems(ITEMS_TEXT);Player player = new Player(100, new Inventory(3, items));System.out.println(buy(player, items, "potion_heal"));System.out.println(buy(player, items, "potion_heal"));System.out.println(buy(player, items, "chain"));System.out.println(buy(player, items, "leather"));System.out.println(buy(player, items, "war_axe"));System.out.println(sell(player, items, 2));System.out.println(buy(player, items, "potion_mana"));System.out.println(buy(player, items, "antidote"));System.out.println(buy(player, items, "leather"));System.out.println(sell(player, items, 1));buys healing potion (15): done, gold 85buys healing potion (15): done, gold 70buys chain mail (90): not enough gold, gold 70buys leather armor (30): done, gold 40buys war axe (80): not enough gold, gold 40sells leather armor (15): done, gold 55buys mana potion (15): done, gold 40buys antidote (10): done, gold 30buys leather armor (30): no room, gold 30sells healing potion (7): done, gold 37The order of the checks matters. The chain mail for 90 cannot be bought with 70 gold, nor the war axe for 80 with 40. On the ninth line the hero has the gold for a leather armor (30), but the bag is full: the gold check passes, canAdd refuses, and no gold is taken. Selling the leather armor pays 15 (30 / 2), and a healing potion 7 (15 / 2, rounded down): buying and straight away selling the same thing is never a profit.
Here is the selling price in the canon. Buying there is the same checks, only the screen reads the number from the keyboard:
static int sellPrice(Item item) { return item.price / 2;}The idea
In the game
What we built is the same as in the canon, only smaller. Here is the real inventory:
static class Inventory { int capacity; List<ItemStack> slots;
Inventory(int capacity) { this.capacity = capacity; this.slots = new ArrayList<>(); }
int count(String itemId) { int total = 0; for (ItemStack slot : this.slots) { if (slot.id.equals(itemId)) total += slot.count; } return total; }
boolean canAdd(String itemId, int count) { Item item = ITEMS.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; int needed = item.stackable ? 1 : count; return this.slots.size() + needed <= this.capacity; }
boolean add(String itemId, int count) { if (!this.canAdd(itemId, count)) return false; Item item = ITEMS.get(itemId); if (item.stackable) { for (ItemStack slot : this.slots) { if (slot.id.equals(itemId)) { slot.count += count; return true; } } this.slots.add(new ItemStack(itemId, count)); return true; } for (int i = 0; i < count; i++) this.slots.add(new ItemStack(itemId, 1)); return true; }
void remove(String itemId, int count) { int left = count; List<ItemStack> kept = new ArrayList<>(); for (ItemStack slot : this.slots) { if (slot.id.equals(itemId) && left > 0) { int taken = Math.min(left, slot.count); slot.count -= taken; left -= taken; } if (slot.count > 0) kept.add(slot); } this.slots = kept; }
void removeAt(int index) { ItemStack slot = this.slots.get(index); slot.count -= 1; if (slot.count <= 0) this.slots.remove(index); }}Equipment in the canon is part of Creature: gearBonus adds up the modifiers over the slots and sits between the base and the effects (see the lesson on heroes and enemies):
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : this.equipment.keySet()) { String itemId = this.equipment.get(slot); if (itemId.equals("")) continue; for (Mod mod : ITEMS.get(itemId).mods) { if (mod.stat.equals(stat)) total += mod.value; } } return total;}
Item weapon() { String id = this.equipment.get("weapon"); return ITEMS.get(id.equals("") ? "fists" : id);}Putting an item on from the bag swaps the old one in at the same place, so the order of the items does not get mixed up:
// Equips the item from the inventory; the old one takes its place in the inventory.static String equipFromInventory(Hero hero, int index) { ItemStack slot = hero.inventory.slots.get(index); Item item = ITEMS.get(slot.id); if (!item.kind.equals("weapon") && !item.kind.equals("armor") && !item.kind.equals("ring")) return ""; String old = hero.equipment.get(item.kind); hero.inventory.removeAt(index); hero.equipment.put(item.kind, item.id); if (!old.equals("")) hero.inventory.slots.add(index, new ItemStack(old, 1)); if (hero.hp > hero.maxHp()) hero.hp = hero.maxHp(); if (hero.mana > hero.maxMana()) hero.mana = hero.maxMana(); return item.id;}And the loot roll, which you know:
// Weighted loot: weight 30 out of a total of 100 means a 30% chance.static ItemStack rollLoot(Random rng, String tableId) { List<LootEntry> entries = LOOT_TABLES.get(tableId); int total = 0; for (LootEntry entry : entries) total += entry.weight; int roll = rng.nextInt(total); for (LootEntry entry : entries) { if (roll < entry.weight) { if (entry.itemId.equals("none")) return null; return new ItemStack(entry.itemId, rng.range(entry.min, entry.max)); } roll -= entry.weight; } return null;}The differences from our program:
- One table per kind. Weapons, armor, rings, potions and the rest each have their own columns; our table is a single one so that it reads easily.
- Names in four languages. Every row in the canon carries the name in
bg,en,deandes;nm(item)picks by the language of the game. Here the name is one cell. - A global
ITEMS. The canon keeps the items in one global dictionary and looks them up byid. We pass them toInventoryandHero, so that the dependencies are visible and testing is easy. - A bag with 10 slots. The hero in the canon has
Inventory(10); here the slots are 3 and 4, so you can see when they fill up. - Gold and potions. Gold is not an item in the bag but a number on the hero (enemies drop gold separately from loot). Potions have an action (
heal,mana,cure) anddrinkPotiondrinks them in battle and on the map; here we only stack them. - Loot falls to the floor. A defeated enemy leaves the item on the floor (
dropItem), and the hero picks it up withg; with a full bag the item stays on the floor and the player sees "the bag is full". In our program the loot goes straight into the bag, and a full bag returnsfalse.
Try it
Predict the output
Three rows with weights 50, 30 and 20 and six rolled numbers. What does the program print?
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 };for (int roll : rolls) { System.out.println(roll + ": " + pick(ids, weights, roll));}The number is compared with the weight of the row, and if it is not smaller, the weight is subtracted. 49 is smaller than 50: the first row comes up; 50 is not smaller than 50, 0 is left and the second row comes up; 79 passes 50 (29 is left) and 29 is smaller than 30: the second; 80 passes 50 and 30 (0 is left) and the third comes up. The second answer compares with <= and gets the boundaries wrong; the third divides the numbers into three equal parts and forgets the weights.
Try it
Find the bug
A colleague rewrote the check in canAdd, and now a bag with 2 slots holds three items, and a potion does not go into a full bag:
Map<String, Item> items = loadItems(ITEMS_TEXT);Inventory bag = new Inventory(2, items);addAndShow(bag, "spear", 1);addAndShow(bag, "potion_heal", 1);addAndShow(bag, "potion_mana", 1);addAndShow(bag, "potion_heal", 1);+ spear: done (1/2)+ healing potion: done (2/2)+ mana potion: done (3/2)+ healing potion: no room (3/2)Which line is broken?
Pick the line with the bug.
The check is inverted: count(itemId) == 0 lets every new stackable item through without looking for room (hence "3/2"), while for an already started stack it falls to the general room check and refuses. Correct: > 0, that is "such an item is already in the bag, no slot is needed".
More precisely
More precisely: probabilities, shares and weak spots
Weights, not percentages. Weights do not have to add up to 100. A table 3, 1, 1 is the same as 60, 20, 20. The total is the divisor and is calculated again for every roll, so a new row is added without touching the rest. Percentages would have to add up to 100 after every change, weights do not.
Subtracting or adding. You can roll a number and compare it with sums (40, 70, 80, 90, 100): row i comes up when the number is below the i-th sum. Subtracting is the same without a second array. For big tables the sums are kept ready and searched with binary search; for the tables of a game that is unnecessary.
"Nothing" is a row. none with weight 40 is a row like any other: with one number a table gives 0% or 90% "nothing". And the table of the leader (leader) in the canon has only one row with weight 100: the ring always drops.
All or nothing. Every change to the bag goes through add, which asks canAdd first. So "half a stack" or "three items in two slots" cannot happen. The exercise below protects exactly that.
Rounding. price / 2 with integer division cuts off the remainder, so a sale never pays more than half. In Python and JavaScript integer division is // and Math.floor; they work correctly because prices are not negative.
Randomness and repeatability. The roll does not know what will come up; it only knows how to answer a number. The generator has a seed (mulberry32), so with the same seed and the same kills the loot is the same in all four languages and in a recorded playthrough. We will need this for saving and for the simulation as well.
Exercise
Stacks with an upper limit
Solve the task in your editor. Start from the inventory program.
In the game, ten potions in one slot is too much. Add an upper limit for a stack: at most 5 of one item per slot. What changes:
- a stack that reaches 5 is full; more of the same goes to a new slot;
addmust be all or nothing: if they do not all fit (into old stacks and into new slots), nothing is put in;- non-stacking items take one slot each, as before.
You need room (how much more fits into old stacks), a corrected canAdd, and an add that first tops up the old stacks and then makes new ones. The bag has 3 slots, and the scenario is this:
static void addAndShow(Inventory bag, String itemId, int count) { boolean added = bag.add(itemId, count); List<String> parts = new ArrayList<>(); for (Slot slot : bag.slots) parts.add(label(bag.items.get(slot.id).name, slot.count)); System.out.println("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "done" : "no room") + " " + fillText(bag) + " [" + String.join(", ", parts) + "]");}
Map<String, Item> items = loadItems(ITEMS_TEXT);Inventory 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);The output is:
+ healing potion x4: done (1/3) [healing potion x4]+ healing potion x3: done (2/3) [healing potion x5, healing potion x2]+ mana potion x2: done (3/3) [healing potion x5, healing potion x2, mana potion x2]+ healing potion x3: done (3/3) [healing potion x5, healing potion x5, mana potion x2]+ healing potion: no room (3/3) [healing potion x5, healing potion x5, mana potion x2]+ mana potion x4: no room (3/3) [healing potion x5, healing potion x5, mana potion x2]The lines show the boundaries too: the second step tops up the old stack to 5 and opens a new slot for the remaining 2; the fourth tops up the already started stack and needs no new slot; the last one (4 more mana potions with a stack of 2) puts nothing in, although 3 of them would fit, because the fourth has nowhere to go.
More tasks
- Sell the junk. Add
sellJunk(player, items): it sells everything of kindmiscand returns how much gold was received. - The best weapon. When the hero picks up a weapon, put it on only if the sum of its modifiers is larger than the sum of the one currently equipped.
- Luck. Give
rollLoota parameterluck: the bigger it is, the more the weight of thenonerow is reduced (but not below 0), and the others stay. What happens to the chances of the others?
Test
Test yourself
Related lessons
Section titled "Related lessons"- Builds on: Throwing and your own exceptions · Lists · HashMap and HashSet · record, switch and patterns
- Tasks on this topic: Chronicles of Arcanum