Skip to content

Commit 9177b20

Browse files
committed
✨ First pass on a crafting implementation.
Also some typing fixes.
1 parent adb88d1 commit 9177b20

File tree

7 files changed

+103
-26
lines changed

7 files changed

+103
-26
lines changed

py/simulator/game.py

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def tick(self, seconds: int) -> None:
3131
self.farm.tick(seconds)
3232

3333
def process_ai(self):
34+
if self.ai is None:
35+
return
3436
if self.farm.can_harvest:
3537
self.log.debug("AI harvest")
3638
self.farm.harvest_all()

py/simulator/items.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ def get(cls, name: str) -> Optional[Item]:
5353
return cls._all_items.get(name)
5454

5555
def growth_time_for(self, player: Player) -> int:
56+
if self.growth_time is None:
57+
raise ValueError(f"{self.name} is not growable")
5658
discount = player.perk_value(
5759
{
5860
"Quicker Farming I": 0.05,
@@ -63,4 +65,4 @@ def growth_time_for(self, player: Player) -> int:
6365
"Irrigation System II": 0.2,
6466
}
6567
)
66-
return self.growth_time * (1 - discount)
68+
return round(self.growth_time * (1 - discount))

py/simulator/locations.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
class _ItemPicker:
1919
"""Helper class for the logic to pick a random item from a dict[str,float] rates bucket."""
2020

21-
def __init__(self, rates: dict[str, float], allow_none: bool = False):
21+
def __init__(self, rates: frozendict[str, float], allow_none: bool = False):
2222
# Copy the dict to be extra sure we get the population and weights in the same order.
2323
pairs = list(rates.items())
2424
self.items: list[Optional[Item]] = [Item[p[0]] for p in pairs]
@@ -76,7 +76,7 @@ def __attrs_post_init__(self):
7676
def _all_locations(cls) -> dict[str, Location]:
7777
"""Lazy load the location data."""
7878
cls._all_locations = {
79-
loc["name"]: cls(**loc)
79+
str(loc["name"]): cls(**loc)
8080
for loc in json.load(
8181
Path(__file__).joinpath("..", "data", "locations.json").resolve().open()
8282
)
@@ -126,14 +126,14 @@ def explore(self, player: Player) -> None:
126126
"Explored",
127127
location=self.name,
128128
effectiveness=effectiveness,
129-
items=[it.name for it in found_items],
129+
# items=[it.name for it in found_items],
130130
base_xp=base_xp,
131131
item_xp=item_xp,
132132
stamina_used=stamina_used,
133133
)
134134
for item in found_items:
135135
player.add_item(item)
136-
player.exploring_xp += base_xp + item_xp
136+
player.exploring_xp += round(base_xp) + item_xp
137137
player.stamina -= stamina_used
138138

139139
def lemonade(self, player: Player) -> None:

py/simulator/player.py

+54-9
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
from __future__ import annotations
22

33
from collections import Counter
4-
from numbers import Number
5-
from typing import TYPE_CHECKING, Optional, TypeVar
4+
from typing import TYPE_CHECKING, Literal, Optional, TypeVar
65

76
import attrs
87
import structlog
98

9+
from .items import Item
10+
1011
if TYPE_CHECKING:
1112
from .game import Game
12-
from .items import Item
1313
from .locations import Location
1414

1515

16-
PerkValue = TypeVar("PerkValue", bound=Number)
16+
PerkValue = TypeVar("PerkValue", int, float)
1717

1818

1919
@attrs.define
@@ -25,15 +25,15 @@ class Player:
2525
fishing_xp: int = 0
2626
crafting_xp: int = 0
2727
exploring_xp: int = 0
28-
inventory: Counter[Item, int] = attrs.field(factory=Counter, converter=Counter)
28+
inventory: Counter[Item] = attrs.field(factory=Counter, converter=Counter)
2929
max_inventory: int = 100
3030
stamina: int = 0
3131
max_stamina: int = 100
3232
perks: set[str] = attrs.Factory(set)
3333
has_all_perks: bool = False
34-
exploring_effectiveness: dict[Location:int] = attrs.Factory(dict)
34+
exploring_effectiveness: dict[Location, int] = attrs.Factory(dict)
3535
# Tracking stuff.
36-
overflow_items: Counter[Item, int] = attrs.field(factory=Counter, converter=Counter)
36+
overflow_items: Counter[Item] = attrs.field(factory=Counter, converter=Counter)
3737
seconds_until_stamina: int = 120
3838

3939
log: structlog.stdlib.BoundLogger = structlog.stdlib.get_logger(mod="player")
@@ -71,7 +71,7 @@ def has_perk(self, perk: str) -> bool:
7171
# TODO validate perk names so I can catch typos.
7272
return self.has_all_perks or perk in self.perks
7373

74-
def perk_value(self, perks: dict[str, PerkValue]) -> PerkValue:
74+
def perk_value(self, perks: dict[str, PerkValue]) -> PerkValue | Literal[0]:
7575
"""Handle the very common case of needing to sum multiple values from different perks."""
7676
return sum(value for perk, value in perks.items() if self.has_perk(perk))
7777

@@ -93,7 +93,7 @@ def sell_item(self, item: Item, quantity: Optional[int] = None) -> None:
9393
silver = quantity * item.sell_price * (1 + sell_bonus)
9494
# This check we have enough of the item.
9595
self.remove_item(item, quantity)
96-
self.silver += silver
96+
self.silver += round(silver)
9797
self.log.debug("Selling item", item=item.name, quantity=quantity, silver=silver)
9898

9999
def buy_item(self, item: Item, quantity: Optional[int] = None) -> None:
@@ -116,3 +116,48 @@ def exploring_effectiveness_for(self, location: Location) -> int:
116116
if self.has_perk("Sprint Shoes II"):
117117
multiplier *= 2
118118
return self.exploring_effectiveness.get(location, 1) * multiplier
119+
120+
def items_needed_to_craft(self, item: Item) -> dict[Item, int]:
121+
"""Return how many of each direct ingredient are not currently available."""
122+
if not item.recipe:
123+
raise ValueError(f"{item.name} is not craftable")
124+
needed: dict[Item, int] = {}
125+
for name, quantity in item.recipe.items():
126+
ingredient = Item[name]
127+
ingredient_needed = quantity - self.inventory[ingredient]
128+
if ingredient_needed > 0:
129+
needed[ingredient] = ingredient_needed
130+
return needed
131+
132+
def craft(self, item: Item) -> None:
133+
if item.craft_price is None:
134+
raise ValueError(f"{item.name} is not craftable")
135+
needed = self.items_needed_to_craft(item)
136+
if needed:
137+
raise ValueError(f"{item.name} is missing ingredients: {needed}")
138+
price_reduction = self.perk_value(
139+
{
140+
"Artisan I": 0.05,
141+
"Artisan II": 0.1,
142+
"Artisan III": 0.15,
143+
"Artisan IV": 0.2,
144+
"Toolbox I": 0.1,
145+
}
146+
)
147+
craft_price = round(item.craft_price * (1 - price_reduction))
148+
if self.silver < craft_price:
149+
raise ValueError("not enough silver")
150+
xp_bonus = self.perk_value(
151+
{
152+
"Crafting Primer": 0.1,
153+
"Crafting Primer II": 0.1,
154+
"Crafting Almanac": 0.1,
155+
}
156+
)
157+
xp = round(item.xp * (1 + xp_bonus))
158+
self.log.debug("Crafting", item=item, price=craft_price, xp=xp)
159+
for name, quantity in item.recipe.items():
160+
self.remove_item(Item[name], quantity)
161+
self.silver -= craft_price
162+
self.add_item(item)
163+
self.crafting_xp += xp

py/simulator/tests/test_items.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def test_growth_time_for_all_perks(super_player):
1414

1515
def test_get():
1616
apple = Item.get("Apple")
17+
assert apple is not None
1718
assert apple.name == "Apple"
1819
assert apple.id == "44"
1920

py/simulator/tests/test_locations.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
def test_get():
88
loc = Location.get("Forest")
9+
assert loc is not None
910
assert loc.type == "explore"
1011
assert "Antler" in loc.items
1112

@@ -87,11 +88,16 @@ def test_explore_drop(player: Player, forest: Location):
8788
assert 1300 < player.inventory[Item["Wood"]] < 1600
8889

8990

90-
def test_explore_wanderer(super_player: Player, forest: Location):
91-
super_player.stamina = 40000
92-
super_player.exploring_effectiveness[forest] = 10000
93-
forest.explore(super_player)
94-
assert 25000 < super_player.stamina < 28000
91+
def test_explore_wanderer(player: Player, forest: Location):
92+
player.stamina = 40000
93+
player.exploring_effectiveness[forest] = 40000
94+
player.perks.add("Wanderer I")
95+
player.perks.add("Wanderer II")
96+
player.perks.add("Wanderer III")
97+
player.perks.add("Wanderer IV")
98+
forest.explore(player)
99+
# Technically this could fail, but not likely.
100+
assert 25000 < player.stamina < 28000
95101

96102

97103
def test_lemonade(player: Player, forest: Location):

py/simulator/tests/test_player.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,60 @@
11
import pytest
22
from simulator.items import Item
3+
from simulator.player import Player
34

45

5-
def test_add_inventory(player):
6+
def test_add_inventory(player: Player):
67
player.max_inventory = 100
78
player.add_item(Item["Wood"])
89
assert player.inventory[Item["Wood"]] == 1
910
assert player.overflow_items[Item["Wood"]] == 0
1011

1112

12-
def test_add_inventory_overflow(player):
13+
def test_add_inventory_overflow(player: Player):
1314
player.max_inventory = 100
1415
player.add_item(Item["Wood"], 300)
1516
assert player.inventory[Item["Wood"]] == 100
1617
assert player.overflow_items[Item["Wood"]] == 200
1718

1819

19-
def test_remove_inventory(player):
20-
player.inventory = {Item["Wood"]: 10}
20+
def test_remove_inventory(player: Player):
21+
player.inventory[Item["Wood"]] = 10
2122
player.remove_item(Item["Wood"])
2223
assert player.inventory[Item["Wood"]] == 9
2324

2425

25-
def test_remove_inventory_underflow(player):
26+
def test_remove_inventory_underflow(player: Player):
2627
with pytest.raises(ValueError):
2728
player.remove_item(Item["Wood"])
2829

2930

30-
def test_stamina(player):
31+
def test_stamina(player: Player):
3132
assert player.stamina == 0
3233
player.tick(60 * 10)
3334
assert player.stamina == 20
3435

3536

36-
def test_stamina_energy_drink(super_player):
37+
def test_stamina_energy_drink(super_player: Player):
3738
assert super_player.stamina == 0
3839
super_player.tick(60 * 10)
3940
assert super_player.stamina == 40
41+
42+
43+
def test_items_needed_to_craft(player: Player):
44+
needed = player.items_needed_to_craft(Item["Fancy Pipe"])
45+
assert needed == {Item["Iron"]: 1, Item["Wood"]: 2, Item["Iron Ring"]: 3}
46+
47+
48+
def test_items_needed_to_craft_partial(player: Player):
49+
player.inventory[Item["Wood"]] = 2
50+
player.inventory[Item["Iron Ring"]] = 2
51+
needed = player.items_needed_to_craft(Item["Fancy Pipe"])
52+
assert needed == {Item["Iron"]: 1, Item["Iron Ring"]: 1}
53+
54+
55+
def test_items_needed_to_craft_over(player: Player):
56+
player.inventory[Item["Iron"]] = 2
57+
player.inventory[Item["Wood"]] = 2
58+
player.inventory[Item["Iron Ring"]] = 5
59+
needed = player.items_needed_to_craft(Item["Fancy Pipe"])
60+
assert needed == {}

0 commit comments

Comments
 (0)