diff --git a/README.md b/README.md index df25190..fc3aad3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. area. Rings for offence (swing speed, crit, power), amulets for defence (max HP, ward, regen), trinkets for the rest (XP, double gathering, masterwork). +- **Stations and storage:** builders make a station for every crafting + skill (anvil stand, jeweller's bench, hearth, alembic, tanning rack, loom), + as fast as the smith's tools for gathering, from the tier's planks and + hardware and another trade's bars, leather or cloth. Builders also make + strongboxes (+2 to +14 bank slots) and homesteaders sew bags (+250 to + +2,000 per stack); each has one slot on the Bank page, and a smaller one + can't go on while the bank holds more than it would. - **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril, adamant, rune at levels 1โ€“75) of ore, trees (up to enchanted), hides (deer, boar, bear, elk, sabrecat, mammoth) and fibres (flax, cotton, silk, diff --git a/app.py b/app.py index d038f9a..6d81e58 100644 --- a/app.py +++ b/app.py @@ -108,7 +108,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} view["slots"] = game.bank_slots(world, player) view["slots_used"] = game.slots_used(world, player) - view["stack"] = game.stack_size(world) + view["stack"] = game.stack_size(world, player) view["power"] = game.combat_power(player) me = game.fighter(player, game.blessing(player), game.potion_effect(player, "combat")) view["fighter"] = {"max_hit": me["max_hit"], "accuracy": round(me["accuracy"]), "evasion": me["evasion"], @@ -429,6 +429,16 @@ class Handler(BaseHTTPRequestHandler): return "Not found" def equip(self, player: dict, item: str | None, slot: str | None) -> str | None: + # A smaller strongbox or bag can't go on while the bank holds more than it would. + world = self.store.world + over, bank, equip = game.overfull(world, player), dict(player["bank"]), dict(player["equip"]) + error = self._equip(player, item, slot) + if not error and game.overfull(world, player) > max(0, over): + player["bank"], player["equip"] = bank, equip + return "Bank too full for that" + return error + + def _equip(self, player: dict, item: str | None, slot: str | None) -> str | None: if item is None: if slot not in player["equip"]: return "No such slot" diff --git a/game.py b/game.py index 9aed496..6c96772 100644 --- a/game.py +++ b/game.py @@ -124,6 +124,20 @@ COPPER_ICONS = {"sword": "๐Ÿ—ก๏ธ", "helmet": "โ›‘๏ธ", "legs": "๐Ÿฆต", "body": TOOLS = {"mining": ("pickaxe", "โ›๏ธ"), "woodcutting": ("axe", "๐Ÿช“"), "farming": ("hoe", "๐Ÿช"), "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช"), "construction": ("hammer", "๐Ÿ”จ")} TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60] +# Crafting stations: the builder's tools for every crafting skill, as fast as the smith's +# tools for gathering. Each takes the tier's planks and hardware and a material from +# another trade. skill: (id suffix, noun, glyph, other material) +STATIONS = { + "smithing": ("anvil_stand", "anvil stand", "anvil-impact", "leather"), + "jewelcrafting": ("jewellers_bench", "jeweller's bench", "gem-chain", "cloth"), + "cooking": ("hearth", "hearth", "campfire", "bar"), + "herblore": ("alembic", "alembic", "erlenmeyer", "bar"), + "leatherworking": ("tanning_rack", "tanning rack", "animal-hide", "cloth"), + "tailoring": ("loom", "loom", "spinning-wheel", "leather"), +} +# Storage: a builder's strongbox adds bank slots, a homesteader's bag a bigger stack. +STRONGBOX_SLOTS = [2, 4, 6, 8, 11, 14] +BAG_STACK = [250, 500, 750, 1000, 1500, 2000] # item id, name, icon, value, action id, verb TREES = [ @@ -290,6 +304,24 @@ for _tier, _metal in enumerate(METALS): ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"), "icon": "๐Ÿช„", "style": "staff", **_weapon} ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "๐Ÿน", "style": "bow", **_weapon} +WOOD_NAMES = ["Wooden", "Oak", "Willow", "Maple", "Yew", "Enchanted"] + + +def station_material(kind: str, tier: int) -> str: + """The other trade's part of a station: the tier's bar, leather or cloth.""" + return {"bar": f"{METALS[tier][0]}_bar", "leather": LEATHERS[tier][1][0], "cloth": CLOTHS[tier][4][0]}[kind] + + +for _tier, _wood in enumerate(WOODS): + for _skill, (_suffix, _noun, _glyph, _kind) in STATIONS.items(): + ITEMS[f"{_wood}_{_suffix}"] = { + "name": f"{WOOD_NAMES[_tier]} {_noun}", "icon": "๐Ÿชš", "tier": _tier, "slot": f"tool_{_skill}", "tool": _skill, + "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]], "value": 0} + ITEMS[f"{_wood}_strongbox"] = {"name": f"{WOOD_NAMES[_tier]} strongbox", "plural": f"{WOOD_NAMES[_tier]} strongboxes", + "icon": "๐Ÿงฐ", "tier": _tier, "slot": "storage", "slots": STRONGBOX_SLOTS[_tier], "value": 0} +for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS): + ITEMS[f"{_prefix}_bag"] = {"name": f"{_label} bag", "icon": "๐Ÿ‘œ", "tier": _tier, "slot": "bag", + "stack": BAG_STACK[_tier], "value": 0} for _tier, (_raw, _leather, _prefix, _label, _level) in enumerate(LEATHERS): ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_leather[0]] = {"name": _leather[1], "icon": "๐Ÿชข", "value": _leather[2]} @@ -400,7 +432,8 @@ for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS: # What the city eats first: plain food before the good stuff. FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"])) -EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket"] + [f"tool_{skill}" for skill in TOOLS] +EQUIP_SLOTS = (["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket", "storage", "bag"] + + [f"tool_{skill}" for skill in [*TOOLS, *STATIONS]]) SKILLS = [ {"id": "mining", "name": "Mining", "icon": "โ›๏ธ", "group": "Gathering"}, @@ -789,6 +822,30 @@ def _weapon_actions() -> list[dict]: return actions +def _storage_actions() -> list[dict]: + """Crafting stations and strongboxes (Construction), and bags (Tailoring).""" + actions = [] + for tier, wood in enumerate(WOODS): + plank, (hardware, *_rest, made) = PLANKS[tier][0], HARDWARE[tier] + for skill, (suffix, _noun, _glyph, kind) in STATIONS.items(): + inputs = {plank: 3, hardware: made, station_material(kind, tier): 2} + actions.append(_action(f"make_{wood}_{suffix}", "construction", ITEMS[f"{wood}_{suffix}"]["name"], + TIER_LEVELS[tier] + 4, 40 + 3 * tier, [14, 30, 48, 68, 90, 115][tier], inputs, + {f"{wood}_{suffix}": 1}, group="Stations")) + inputs = {plank: 4, hardware: 2 * made, LEATHERS[tier][1][0]: 2} + actions.append(_action(f"make_{wood}_strongbox", "construction", ITEMS[f"{wood}_strongbox"]["name"], + TIER_LEVELS[tier] + 8, 45 + 3 * tier, [18, 38, 60, 85, 112, 142][tier], inputs, + {f"{wood}_strongbox": 1}, group="Storage")) + for tier, (_fibre, _level, _time, _xp, cloth, prefix, label) in enumerate(CLOTHS): + actions.append(_action(f"sew_{prefix}_bag", "tailoring", f"{label} bag", TIER_LEVELS[tier] + 8, 45 + 3 * tier, + [18, 38, 60, 85, 112, 142][tier], {cloth[0]: 4, LEATHERS[tier][1][0]: 1}, + {f"{prefix}_bag": 1}, group=label)) + for action in actions: + made_item = ITEMS[next(iter(action["outputs"]))] + made_item["value"] = round(sum(ITEMS[item]["value"] * qty for item, qty in action["inputs"].items()) * 1.3) + return actions + + def _jewel_actions() -> list[dict]: """Silver and gold, prospecting ore for gems, and every tier's jewellery.""" actions = [ @@ -1033,7 +1090,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 20 +WORLD_VERSION = 21 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1190,6 +1247,12 @@ def migrate(world: dict, now: float | None = None) -> dict: if world["version"] < 20: _precision_to_haste(world) world["version"] = 20 + if world["version"] < 21: + # Crafting stations, strongboxes and bags: their slots. + for player in world["players"].values(): + for slot in EQUIP_SLOTS: + player["equip"].setdefault(slot, None) + world["version"] = 21 return world @@ -1459,22 +1522,29 @@ def skill_level(player: dict, skill: str) -> int: def bank_slots(world: dict, player: dict) -> int: return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"] - + player["perks"]["bank_slot"] + fitted(world, "shelving")) + + player["perks"]["bank_slot"] + fitted(world, "shelving") + int(gear_bonus(player, "slots"))) -def stack_size(world: dict) -> int: - return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests") +def stack_size(world: dict, player: dict | None = None) -> int: + """How many of one item fit in a bank slot: the storehouse's, plus your bag's.""" + bag = int(gear_bonus(player, "stack")) if player else 0 + return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests") + bag + + +def overfull(world: dict, player: dict) -> int: + """Bank slots in use past what the bank holds (after a smaller strongbox or bag, say).""" + return slots_used(world, player) - bank_slots(world, player) def slots_used(world: dict, player: dict) -> int: """Bank slots taken: an item spills into another slot every full stack.""" - stack = stack_size(world) + stack = stack_size(world, player) return sum(-(-qty // stack) for qty in player["bank"].values()) def bank_room(world: dict, player: dict, item: str) -> int: """How many more of `item` fit: the rest of its last stack, plus any free slots.""" - stack = stack_size(world) + stack = stack_size(world, player) have = player["bank"].get(item, 0) partial = -have % stack # room left in the item's last, part-filled stack free = max(0, bank_slots(world, player) - slots_used(world, player)) @@ -3320,7 +3390,7 @@ FITTINGS = [ for _weapon in _weapon_actions(): ACTIONS.append(_weapon) ACTION_BY_ID[_weapon["id"]] = _weapon -for _jewel in _jewel_actions(): +for _jewel in [*_jewel_actions(), *_storage_actions()]: ACTIONS.append(_jewel) ACTION_BY_ID[_jewel["id"]] = _jewel FITTING_BY_ID = {} @@ -4026,6 +4096,12 @@ def _assign_glyphs() -> None: ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) for herb, tint in HERB_TINTS.items(): ITEMS[herb].update(glyph="herbs-bundle", tint=tint) + for tier, (wood, tint) in enumerate(zip(WOODS, WOOD_TINTS)): + for suffix, _noun, glyph, _kind in STATIONS.values(): + ITEMS[f"{wood}_{suffix}"].update(glyph=glyph, tint=tint) + ITEMS[f"{wood}_strongbox"].update(glyph="locked-chest", tint=tint) + for cloth, tint in zip(CLOTHS, CLOTH_TINTS): + ITEMS[f"{cloth[5]}_bag"].update(glyph="shoulder-bag", tint=tint) for gem, _name, _value, tint, glyph in GEMS: ITEMS[gem].update(glyph=glyph, tint=tint) for key, *_rest, piece_glyph in JEWELLERY: @@ -4088,6 +4164,6 @@ def content() -> dict: "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE, - "resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, + "resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, "regen_per_hour": REGEN_PER_HOUR, } diff --git a/static/app.js b/static/app.js index 61b501d..eb63973 100644 --- a/static/app.js +++ b/static/app.js @@ -510,14 +510,14 @@ function skillGear(skill) { ${others ? `
${others}
` : ""}`); } if (`tool_${skill}` in me.equip) { - const id = me.equip[`tool_${skill}`], it = id && item(id); + const id = me.equip[`tool_${skill}`], it = id && item(id), noTool = C.stations.includes(skill) ? "No station" : "No tool"; const others = Object.keys(me.bank).filter((x) => item(x).tool === skill).sort((a, b) => item(b).speed - item(a).speed).map((x) => { const [s, level] = item(x).req || [], why = s && me.levels[s] < level ? `Needs ${skillOf(s).name.toLowerCase()} ${level}` : ""; return ``; }).join(""); cards.push(`
- ${it ? it.icon : skillOf(skill).icon} -
${it ? `${esc(it.name)}${Math.round(it.speed * 100)}% faster` : `No tool`}
+ ${it ? it.icon : skillOf(skill).icon} +
${it ? `${esc(it.name)}${Math.round(it.speed * 100)}% faster` : `${noTool}`}
${others || it ? `
${others}${it ? `` : ""}
` : ""}
`); } return cards.length ? `
${cards.join("")}
` : ""; @@ -731,6 +731,8 @@ function itemStats(id) { if (it.reagent) parts.push(it.reagent === "jewel" ? "Reagent for jewellery" : `Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`); if (it.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it)); if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`); + if (it.slots) parts.push(storageText(it)); + if (it.stack) parts.push(storageText(it)); if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`); if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`); if (it.trophy) parts.push(`Trophy for stronger weapons`); @@ -817,6 +819,26 @@ function actionCard(a) { // --- bank ----------------------------------------------------------------- +// A strongbox's extra slots or a bag's bigger stack. +function storageText(it) { + return it.slots ? `+${it.slots} bank slots` : `+${fmt(it.stack)} per stack`; +} + +// The strongbox and bag slots, each with what else you could swap in. +function storageGear() { + const me = S.me; + const card = (slot, empty, outline) => { + const id = me.equip[slot], it = id && item(id), size = (x) => x.slots || x.stack; + const others = Object.keys(me.bank).filter((x) => item(x).slot === slot).sort((a, b) => size(item(b)) - size(item(a))) + .map((x) => ``).join(""); + return `
+ ${it ? it.icon : glyph(outline, "#98a1b4")} +
${it ? `${esc(it.name)}${storageText(it)}` : `${empty}`}
+ ${others || it ? `
${others}${it ? `` : ""}
` : ""}
`; + }; + return `
${card("storage", "No strongbox", "locked-chest")}${card("bag", "No bag", "shoulder-bag")}
`; +} + function bank() { const me = S.me; const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0])); @@ -833,6 +855,7 @@ function bank() { `).join(""); for (let i = stacks.length; i < me.slots; i++) slots += `
`; return ` + ${storageGear()}
@@ -890,7 +913,7 @@ function equipRow(id, it) { } function equipLabel(it) { - if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`; + if (it.tool) return `Use for ${skillOf(it.tool).name.toLowerCase()} (${Math.round(it.speed * 100)}% faster)`; return "Equip"; } diff --git a/static/icons.svg b/static/icons.svg index 78cdfb2..a326cca 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -5,6 +5,7 @@ + @@ -26,6 +27,7 @@ + @@ -60,6 +62,7 @@ + @@ -70,6 +73,7 @@ + @@ -105,6 +109,7 @@ + @@ -151,6 +156,7 @@ + @@ -159,6 +165,7 @@ + diff --git a/tests/test_game.py b/tests/test_game.py index ea48768..b877619 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1729,3 +1729,48 @@ class JewelcraftingTest(unittest.TestCase): self.assertEqual(self.ann["bank"], {"garnet_haste": 2, "garnet": 1}) self.assertEqual(self.ann["equip"]["ring"], "quartz_haste") self.assertEqual(self.ann["action"]["id"], "jc_garnet_haste") + + +class StationAndStorageTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("bob", "commoner")) + self.bob = self.world["players"]["bob"] + + def test_recipes_pull_in_other_trades(self): + self.assertEqual(game.ACTION_BY_ID["make_oak_loom"]["inputs"], + {"oak_plank": 3, "iron_nails": 5, "hard_leather": 2}) + self.assertEqual(game.ACTION_BY_ID["make_wooden_hearth"]["inputs"], + {"plank": 3, "copper_rivets": 5, "copper_bar": 2}) + self.assertEqual(game.ACTION_BY_ID["make_oak_strongbox"]["inputs"], + {"oak_plank": 4, "iron_nails": 10, "hard_leather": 2}) + self.assertEqual(game.ACTION_BY_ID["sew_cotton_bag"]["inputs"], {"cotton_cloth": 4, "hard_leather": 1}) + self.assertEqual(game.ACTION_BY_ID["sew_cotton_bag"]["skill"], "tailoring") + + def test_a_station_speeds_its_craft(self): + smelt = game.ACTION_BY_ID["smith_copper_bar"] + bare = game.action_time(self.world, self.bob, smelt) + self.bob["equip"]["tool_smithing"] = "oak_anvil_stand" + self.assertAlmostEqual(game.action_time(self.world, self.bob, smelt), bare * 0.7) + + def test_strongbox_adds_slots_and_bag_a_bigger_stack(self): + slots, stack = game.bank_slots(self.world, self.bob), game.stack_size(self.world, self.bob) + self.bob["bank"] = {"stone": 1200} + self.assertEqual(game.slots_used(self.world, self.bob), 2) + self.bob["equip"].update(storage="oak_strongbox", bag="linen_bag") + self.assertEqual(game.bank_slots(self.world, self.bob), slots + 4) + self.assertEqual(game.stack_size(self.world, self.bob), stack + 250) + self.assertEqual(game.slots_used(self.world, self.bob), 1) + + def test_no_smaller_strongbox_while_the_bank_is_full(self): + import types + import app + handler = types.SimpleNamespace(store=types.SimpleNamespace(world=self.world)) + handler._equip = lambda *args: app.Handler._equip(handler, *args) + self.bob["equip"]["storage"] = "oak_strongbox" + self.bob["bank"] = {f"{wood}_plank" if wood != "wooden" else "plank": 1 for wood in game.WOODS} + self.bob["bank"].update({item: 1 for item in ("stone", "logs", "coal", "bones", "wheat", "flax", "rope", + "bricks", "thatch", "wooden_strongbox")}) + self.assertEqual(game.overfull(self.world, self.bob), 0) # 16 items in 12 + 4 slots: room for the swap, not after + self.assertEqual(app.Handler.equip(handler, self.bob, "wooden_strongbox", None), "Bank too full for that") + self.assertEqual(self.bob["equip"]["storage"], "oak_strongbox") + self.assertEqual(self.bob["bank"]["wooden_strongbox"], 1)