Skip to content
wannacode

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|modifiers
spear|spear|weapon|20|accuracy:5
war_axe|war axe|weapon|80|accuracy:-5
leather|leather armor|armor|30|defense:2
chain|chain mail|armor|90|defense:4,evasion:-5
ring_life|ring of life|ring|70|maxHp:10
ring_agility|ring of agility|ring|70|agility:2
potion_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.

@dataclass(frozen=True)
class Mod:
stat: str
value: int
class Item:
def __init__(self, item_id, name, kind, price, mods):
self.id = item_id
self.name = name
self.kind = kind
self.price = price
self.mods = mods
self.stackable = kind == "potion" or kind == "misc"
# One row of the table is one item: id|name|kind|price|modifiers.
def parse_rows(text, columns):
rows = []
for raw in text.split("\n"):
line = raw.strip()
if len(line) == 0 or line[0] == "#":
continue
cells = line.split("|")
if len(cells) != columns:
raise ValueError("expected " + str(columns) + " cells in the row: " + line)
rows.append(cells)
return rows
# "-" means "no modifiers"; otherwise "stat:value" pairs separated by commas.
def parse_mods(text):
mods = []
if text == "-":
return mods
for part in text.split(","):
pieces = part.split(":")
mods.append(Mod(pieces[0], int(pieces[1])))
return mods
def load_items(text):
items = {}
for c in parse_rows(text, 5):
items[c[0]] = Item(c[0], c[1], c[2], int(c[3]), parse_mods(c[4]))
return items
items = load_items(ITEMS_TEXT)
for item in items.values():
print(item.name + " (" + item.kind + ") " + str(item.price) + " gold: " + mods_text(item.mods))
print("total: " + str(len(items)))
try:
load_items(BAD_ROW)
except ValueError as e:
print(e)
Output
spear (weapon) 20 gold: accuracy +5
war axe (weapon) 80 gold: accuracy -5
leather armor (armor) 30 gold: defense +2
chain mail (armor) 90 gold: defense +4, evasion -5
ring of life (ring) 70 gold: maxHp +10
ring of agility (ring) 70 gold: agility +2
healing potion (potion) 15 gold: -
mana potion (potion) 15 gold: -
antidote (potion) 10 gold: -
silver ore (misc) 8 gold: -
total: 10
expected 5 cells in the row: ring_life|ring of life|ring|70

The 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.

dict keeps the insertion order. The table is a string with triple quotes, and strip() removes the leading and trailing newline.

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:

def parse_rows(text, columns, table_name):
rows = []
for raw_line in text.split('\n'):
line = trim_spaces(raw_line)
if len(line) == 0 or line[0] == '#':
continue
cells = line.split('|')
if len(cells) != columns:
raise RuntimeError(table_name + ': expected ' + str(columns) + ' cells in: ' + line)
rows.append(cells)
return rows
def parse_mods(text):
mods = []
for part in split_list(text):
pieces = part.split(':')
mods.append(SimpleNamespace(stat=pieces[0], value=to_int(pieces[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".
WEAPONS_DATA = """
# id|name|damage type|min|max|price|modifiers
fists|fists|blunt|1|2|0|-
hand_axe|hand axe|slash|4|7|20|-
spear|spear|pierce|3|6|20|accuracy:5
staff|staff|blunt|2|4|10|maxMana:4
war_axe|war axe|slash|6|10|80|accuracy:-5
longbow|longbow|pierce|5|8|80|accuracy:10
mace|mace|blunt|5|8|60|-
rune_staff|rune staff|blunt|3|6|75|maxMana:10
silver_sword|silver sword|slash|7|11|120|accuracy:5
"""
ARMORS_DATA = """
# id|name|price|modifiers
robe|robe|5|maxMana:2
leather|leather armor|30|defense:2
chain|chain mail|90|defense:4,evasion:-5
scale|scale armor|110|defense:5
"""
RINGS_DATA = """
# id|name|price|modifiers
ring_strength|ring of strength|70|strength:2
ring_agility|ring of agility|70|agility:2
ring_life|ring of life|70|maxHp:10
ring_mana|ring of mana|70|maxMana:8
"""
POTIONS_DATA = """
# id|name|price|action|power
potion_heal|healing potion|15|heal|20
potion_mana|mana potion|15|mana|15
antidote|antidote|10|cure|0
"""
MISC_DATA = """
# id|name|price
ore|silver ore|8
gold|gold|0
"""
# Loot: weighted rows for each table. "none" is "nothing"; the count is from min to max.
LOOT_DATA = """
# table|item|weight|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
"""
SHOP_DATA = """
# what the shop sells, in this order
potion_heal
potion_mana
antidote
leather
chain
war_axe
longbow
mace
rune_staff
ring_life
ring_mana
"""
# The XP needed for each level: XP_TABLE[level] is the total XP that reaches that level.
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.

SLOTS = ["weapon", "armor", "ring"]
class Hero:
def __init__(self, name, items):
self.name = name
self.items = items
self.base_stats = {}
self.equipment = {}
self.hp = 0
for slot in SLOTS:
self.equipment[slot] = ""
# The base plus the bonus from everything that is equipped.
def gear_bonus(self, stat):
total = 0
for slot in SLOTS:
item_id = self.equipment[slot]
if len(item_id) == 0:
continue
for mod in self.items[item_id].mods:
if mod.stat == stat:
total += mod.value
return total
def stat(self, stat):
return self.base_stats.get(stat, 0) + self.gear_bonus(stat)
# Life cannot stay above the new maximum.
def clamp_hp(self):
if self.hp > self.stat("maxHp"):
self.hp = self.stat("maxHp")
def equip(self, item_id):
item = self.items[item_id]
old = self.equipment[item.kind]
self.equipment[item.kind] = item_id
self.clamp_hp()
return old
def unequip(self, slot):
self.equipment[slot] = ""
self.clamp_hp()

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.

items = load_items(ITEMS_TEXT)
hero = Hero("Iva", items)
hero.base_stats["maxHp"] = 30
hero.base_stats["defense"] = 1
hero.base_stats["accuracy"] = 70
hero.hp = hero.stat("maxHp")
print(hero.name + ": " + stats(hero))
for item_id in ["spear", "leather", "ring_life"]:
hero.equip(item_id)
print("+ " + items[item_id].name + ": " + stats(hero))
hero.hp = hero.stat("maxHp")
print("heal: " + stats(hero))
hero.equip("war_axe")
print("+ " + items["war_axe"].name + ": " + stats(hero))
hero.unequip("ring")
print("- " + items["ring_life"].name + ": " + stats(hero))
Output
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 75
heal: 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 65

The 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:
def __init__(self, capacity, items):
self.capacity = capacity
self.items = items
self.slots = []
def count(self, item_id):
total = 0
for slot in self.slots:
if slot.id == item_id:
total += slot.count
return total
def can_add(self, item_id, count):
item = self.items[item_id]
if item.stackable and self.count(item_id) > 0:
return True
needed = 1 if item.stackable else count
return len(self.slots) + needed <= self.capacity
def add(self, item_id, count):
if not self.can_add(item_id, count):
return False
item = self.items[item_id]
if item.stackable:
for slot in self.slots:
if slot.id == item_id:
slot.count += count
return True
self.slots.append(Slot(item_id, count))
return True
for _ in range(count):
self.slots.append(Slot(item_id, 1))
return True
def remove(self, item_id, count):
left = count
kept = []
for slot in self.slots:
if slot.id == item_id and left > 0:
taken = min(left, slot.count)
slot.count -= taken
left -= taken
if slot.count > 0:
kept.append(slot)
self.slots = kept
def remove_at(self, index):
slot = self.slots[index]
slot.count -= 1
if slot.count <= 0:
del self.slots[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".

def label(name, count):
return name + " x" + str(count) if count > 1 else name
def fill_text(bag):
return "(" + str(len(bag.slots)) + "/" + str(bag.capacity) + ")"
def print_bag(bag):
print("The bag:")
for i in range(len(bag.slots)):
slot = bag.slots[i]
print(str(i + 1) + ") " + label(bag.items[slot.id].name, slot.count))
def add_and_show(bag, item_id, count):
added = bag.add(item_id, count)
print("+ " + label(bag.items[item_id].name, count) + ": " + ("done" if added else "no room") + " " + fill_text(bag))
items = load_items(ITEMS_TEXT)
bag = Inventory(4, items)
add_and_show(bag, "potion_heal", 2)
add_and_show(bag, "spear", 1)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "potion_mana", 1)
add_and_show(bag, "leather", 1)
add_and_show(bag, "chain", 1)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "ore", 1)
print_bag(bag)
bag.remove("potion_heal", 3)
print("- " + label(items["potion_heal"].name, 3) + ": " + fill_text(bag))
bag.remove("spear", 1)
print("- " + items["spear"].name + ": " + fill_text(bag))
add_and_show(bag, "chain", 1)
print_bag(bag)
Output
+ 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 x4
2) spear
3) mana potion
4) leather armor
- healing potion x3: (4/4)
- spear: (3/4)
+ chain mail: done (4/4)
The bag:
1) healing potion
2) mana potion
3) leather armor
4) chain mail

The 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|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

The 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.

@dataclass(frozen=True)
class Entry:
item_id: str
weight: int
min: int
max: int
@dataclass(frozen=True)
class Drop:
id: str
count: int
def load_loot(text):
tables = {}
for c in parse_rows(text, 5):
if c[0] not in tables:
tables[c[0]] = []
tables[c[0]].append(Entry(c[1], int(c[2]), int(c[3]), int(c[4])))
return tables
def total_weight(entries):
total = 0
for entry in 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.
def roll_loot(rng, entries):
roll = rng.next_int(total_weight(entries))
for entry in entries:
if roll < entry.weight:
if entry.item_id == "none":
return None
return Drop(entry.item_id, rng.range(entry.min, entry.max))
roll -= entry.weight
return None
# 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.
def roll_loot(rng, entries):
    roll = rng.next_int(total_weight(entries))
    for entry in entries:
        if roll < entry.weight:
            if entry.item_id == "none":
                return None
            return Drop(entry.item_id, rng.range(entry.min, entry.max))
        roll  entry.weight
    return None

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:

items = load_items(ITEMS_TEXT)
tables = load_loot(LOOT_TEXT)
rng = Random(6)
bag = Inventory(3, items)
for i in range(len(KILLS)):
drop = roll_loot(rng, tables[KILLS[i]])
head = "enemy " + str(i + 1) + " (" + KILLS[i] + "): "
if drop is None:
print(head + "nothing")
continue
added = bag.add(drop.id, drop.count)
print(head + label(items[drop.id].name, drop.count) + " -> " + ("done" if added else "no room") + " " + fill_text(bag))
print_bag(bag)
Output
enemy 1 (goblin): nothing
enemy 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 x2
2) spear
3) silver ore x2

The 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.

entries = load_loot(LOOT_TEXT)["goblin"]
rng = Random(2026)
counts = {}
for i in range(1000):
drop = roll_loot(rng, entries)
key = "none" if drop is None else drop.id
counts[key] = counts.get(key, 0) + 1
total = total_weight(entries)
for entry in entries:
print(entry.item_id + ": " + str(counts.get(entry.item_id, 0)) + " (expected " + str(entry.weight * 1000 // total) + ")")
Output
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.
def roll_loot(rng, entries):
roll = rng.next_int(total_weight(entries))
for entry in entries:
if roll < entry.weight:
if entry.item_id == "none":
return None
return Drop(entry.item_id, rng.range(entry.min, entry.max))
roll -= entry.weight
return None
def can_add(self, item_id, count):
item = self.items[item_id]
if item.stackable and self.count(item_id) > 0:
return True
needed = 1 if item.stackable else count
return len(self.slots) + needed <= self.capacity
def add(self, item_id, count):
if not self.can_add(item_id, count):
return False
item = self.items[item_id]
if item.stackable:
for slot in self.slots:
if slot.id == item_id:
slot.count += count
return True
self.slots.append(Slot(item_id, count))
return True
for _ in range(count):
self.slots.append(Slot(item_id, 1))
return True
Step by step, in words
  1. 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.
  2. 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).
  3. “nothing” is a row like any other (weight 40): no loot, and the bag is not touched.
  4. 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).
  5. Healing potion goes into the bag in a new slot (1/3).
  6. 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).
  7. Spear goes into the bag in a new slot (2/3).
  8. 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).
  9. Silver ore x2 goes into the bag in a new slot (3/3).
  10. 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).
  11. 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).
  12. 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).
  13. Healing potion is already in the bag, so it stacks in the same slot (healing potion x2); the used slots stay at 3/3.
  14. 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.
def sell_price(item):
return item.price // 2
def buy(player, items, item_id):
item = items[item_id]
result = "done"
if player.gold < item.price:
result = "not enough gold"
elif not player.bag.can_add(item_id, 1):
result = "no room"
else:
player.gold -= item.price
player.bag.add(item_id, 1)
return "buys " + item.name + " (" + str(item.price) + "): " + result + ", gold " + str(player.gold)
def sell(player, items, slot_number):
slot = player.bag.slots[slot_number - 1]
item = items[slot.id]
price = sell_price(item)
player.bag.remove_at(slot_number - 1)
player.gold += price
return "sells " + item.name + " (" + str(price) + "): done, gold " + str(player.gold)
items = load_items(ITEMS_TEXT)
player = Player(100, Inventory(3, items))
print(buy(player, items, "potion_heal"))
print(buy(player, items, "potion_heal"))
print(buy(player, items, "chain"))
print(buy(player, items, "leather"))
print(buy(player, items, "war_axe"))
print(sell(player, items, 2))
print(buy(player, items, "potion_mana"))
print(buy(player, items, "antidote"))
print(buy(player, items, "leather"))
print(sell(player, items, 1))
Output
buys healing potion (15): done, gold 85
buys healing potion (15): done, gold 70
buys chain mail (90): not enough gold, gold 70
buys leather armor (30): done, gold 40
buys war axe (80): not enough gold, gold 40
sells leather armor (15): done, gold 55
buys mana potion (15): done, gold 40
buys antidote (10): done, gold 30
buys leather armor (30): no room, gold 30
sells healing potion (7): done, gold 37

The 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:

def sell_price(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:

# The inventory has a limited number of slots. Identical potions and ore stack in one slot.
class Inventory:
def __init__(self, capacity):
self.capacity = capacity
self.slots = []
def count(self, item_id):
total = 0
for slot in self.slots:
if slot.id == item_id:
total += slot.count
return total
def can_add(self, item_id, count):
item = ITEMS[item_id]
if item.stackable and self.count(item_id) > 0:
return True
needed = 1 if item.stackable else count
return len(self.slots) + needed <= self.capacity
def add(self, item_id, count):
if not self.can_add(item_id, count):
return False
item = ITEMS[item_id]
if item.stackable:
for slot in self.slots:
if slot.id == item_id:
slot.count += count
return True
self.slots.append(SimpleNamespace(id=item_id, count=count))
return True
for i in range(count):
self.slots.append(SimpleNamespace(id=item_id, count=1))
return True
def remove(self, item_id, count):
left = count
kept = []
for slot in self.slots:
if slot.id == item_id and left > 0:
taken = min(left, slot.count)
slot.count -= taken
left -= taken
if slot.count > 0:
kept.append(slot)
self.slots = kept
def remove_at(self, index):
slot = self.slots[index]
slot.count -= 1
if slot.count <= 0:
del self.slots[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):

def gear_bonus(self, stat):
total = 0
for slot in self.equipment.keys():
item_id = self.equipment[slot]
if item_id == '':
continue
for mod in ITEMS[item_id].mods:
if mod.stat == stat:
total += mod.value
return total
def weapon(self):
weapon_id = self.equipment['weapon']
return ITEMS['fists' if weapon_id == '' else weapon_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.
def equip_from_inventory(hero, index):
slot = hero.inventory.slots[index]
item = ITEMS[slot.id]
if item.kind != 'weapon' and item.kind != 'armor' and item.kind != 'ring':
return ''
old = hero.equipment[item.kind]
hero.inventory.remove_at(index)
hero.equipment[item.kind] = item.id
if old != '':
hero.inventory.slots.insert(index, SimpleNamespace(id=old, count=1))
if hero.hp > hero.max_hp():
hero.hp = hero.max_hp()
if hero.mana > hero.max_mana():
hero.mana = hero.max_mana()
return item.id

And the loot roll, which you know:

# Weighted loot: weight 30 out of a total of 100 means a 30% chance.
def roll_loot(rng, table_id):
entries = LOOT_TABLES[table_id]
total = 0
for entry in entries:
total += entry.weight
roll = rng.next_int(total)
for entry in entries:
if roll < entry.weight:
if entry.item_id == 'none':
return None
return SimpleNamespace(id=entry.item_id, count=rng.range(entry.min, entry.max))
roll -= entry.weight
return None

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, de and es; 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 by id. We pass them to Inventory and Hero, 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) and drinkPotion drinks 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 with g; 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 returns false.

Try it

Predict the output

Three rows with weights 50, 30 and 20 and six rolled numbers. What does the program print?

def pick(ids, weights, roll):
for i in range(len(ids)):
if roll < weights[i]:
return ids[i]
roll -= weights[i]
return ids[len(ids) - 1]
ids = ["none", "potion_heal", "spear"]
weights = [50, 30, 20]
rolls = [0, 49, 50, 79, 80, 99]
for roll in rolls:
print(str(roll) + ": " + pick(ids, weights, roll))
Answer

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:

items = load_items(ITEMS_TEXT)
bag = Inventory(2, items)
add_and_show(bag, "spear", 1)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "potion_mana", 1)
add_and_show(bag, "potion_heal", 1)
Output
+ 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.

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;
  • add must 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:

def add_and_show(bag, item_id, count):
added = bag.add(item_id, count)
parts = []
for slot in bag.slots:
parts.append(label(bag.items[slot.id].name, slot.count))
print("+ " + label(bag.items[item_id].name, count) + ": " + ("done" if added else "no room") + " " + fill_text(bag) + " [" + ", ".join(parts) + "]")
items = load_items(ITEMS_TEXT)
bag = Inventory(3, items)
add_and_show(bag, "potion_heal", 4)
add_and_show(bag, "potion_heal", 3)
add_and_show(bag, "potion_mana", 2)
add_and_show(bag, "potion_heal", 3)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "potion_mana", 4)

The output is:

Output
+ 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 kind misc and 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 rollLoot a parameter luck: the bigger it is, the more the weight of the none row is reduced (but not below 0), and the others stay. What happens to the chances of the others?

Test

Test yourself

1. Why are the items of the game a table in text and not new Item(...) in the code?
2. The hero has 40 life with a ring of life (+10 maximum life). The life is full: 40/40. They take the ring off. How much life is left?
3. The bag has 4 slots and all of them are taken. There is a healing potion in it. Can one more such potion be added?
4. The goblin’s table is: nothing 40, healing potion 30, mana potion 10, spear 10, leather armor 10. The roll is 85. Which item drops?
5. A healing potion costs 15. For how much does the hero sell it in the shop?
6. In 1000 defeated goblins the program counts 396 times “nothing”, not exactly 400. Why?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·