From 91a5263acf751d337c8da36bef21daf8f62c54d2 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 20 Oct 2026 21:20:25 +0100 Subject: [PATCH] Every crafting skill gets a tool and a station Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++++++---- game.py | 37 ++++++++++++++++++++++++++++--------- static/app.js | 35 +++++++++++++++++++++-------------- static/icons.svg | 7 +++++++ static/styles.css | 3 ++- tests/test_game.py | 25 ++++++++++++++++++++++++- 6 files changed, 90 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index fc3aad3..ec9953c 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,14 @@ 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 +- **Tools and stations:** smiths make every tool: one per gathering skill and + one per crafting skill (chisel, gem drill, hammer, cleaver, mortar and + pestle, awl, shears). Builders make every station, a second slot for each + crafting skill (anvil stand, jeweller's bench, workbench, hearth, alembic, + tanning rack, loom), from the tier's planks and hardware and another + trade's bars, leather or cloth. Both run 15โ€“60% faster by tier, and a + station speeds what's made at it, not the building site. +- **Storage:** builders 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. diff --git a/game.py b/game.py index b0adba1..aa73bdf 100644 --- a/game.py +++ b/game.py @@ -120,14 +120,18 @@ def _armour_stats(weight: str, slot: str, tier: int) -> dict: PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"} PIECE_ICONS = {"sword": "โš”๏ธ", "shield": "๐Ÿ›ก๏ธ", "helmet": "๐Ÿช–", "legs": "๐Ÿฆฟ", "body": "๐Ÿฆพ"} COPPER_ICONS = {"sword": "๐Ÿ—ก๏ธ", "helmet": "โ›‘๏ธ", "legs": "๐Ÿฆต", "body": "๐Ÿฅ‹"} -# Gathering tools: one slot per skill, `speed` cuts the action time. +# Tools: one slot per skill, `speed` cuts the action time. Smiths make every tool. TOOLS = {"mining": ("pickaxe", "โ›๏ธ"), "woodcutting": ("axe", "๐Ÿช“"), "farming": ("hoe", "๐Ÿช"), - "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช"), "construction": ("hammer", "๐Ÿ”จ")} + "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช"), "construction": ("hammer", "๐Ÿ”จ"), + "smithing": ("chisel", "๐Ÿ”ง"), "jewelcrafting": ("gem drill", "๐Ÿช›"), "cooking": ("cleaver", "๐Ÿ”ช"), + "herblore": ("mortar and pestle", "๐Ÿฅฃ"), "leatherworking": ("awl", "๐Ÿ“"), "tailoring": ("shears", "โœ‚๏ธ")} 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) +# Crafting stations: a second slot for every crafting skill, beside its tool, and as fast. +# Builders make every station, from the tier's planks and hardware and a material from +# another trade. A station speeds what's made at it, not work like the building site. +# skill: (id suffix, noun, glyph, other material) STATIONS = { + "construction": ("workbench", "workbench", "toolbox", "bar"), "smithing": ("anvil_stand", "anvil stand", "anvil-impact", "leather"), "jewelcrafting": ("jewellers_bench", "jeweller's bench", "gem-chain", "cloth"), "cooking": ("hearth", "hearth", "campfire", "bar"), @@ -315,7 +319,7 @@ def station_material(kind: str, tier: int) -> str: 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, + "name": f"{WOOD_NAMES[_tier]} {_noun}", "icon": "๐Ÿชš", "tier": _tier, "slot": f"station_{_skill}", "station": _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} @@ -433,7 +437,7 @@ 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", "storage", "bag"] - + [f"tool_{skill}" for skill in [*TOOLS, *STATIONS]]) + + [f"tool_{skill}" for skill in TOOLS] + [f"station_{skill}" for skill in STATIONS]) SKILLS = [ {"id": "mining", "name": "Mining", "icon": "โ›๏ธ", "group": "Gathering"}, @@ -1090,7 +1094,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 22 +WORLD_VERSION = 23 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1257,6 +1261,17 @@ def migrate(world: dict, now: float | None = None) -> dict: for player in world["players"].values(): player.setdefault("tracked", None) world["version"] = 22 + if world["version"] < 23: + # Stations got their own slot: crafting skills have a tool and a station now. + for player in world["players"].values(): + equip = player["equip"] + for slot in EQUIP_SLOTS: + equip.setdefault(slot, None) + for skill in STATIONS: + item = equip.get(f"tool_{skill}") + if item and ITEMS[item].get("station"): + equip[f"tool_{skill}"], equip[f"station_{skill}"] = None, item + world["version"] = 23 return world @@ -1884,6 +1899,9 @@ def action_time(world: dict, player: dict, action: dict) -> float: tool = player["equip"].get(f"tool_{action['skill']}") if tool: seconds *= 1 - ITEMS[tool]["speed"] + station = player["equip"].get(f"station_{action['skill']}") + if station and action["outputs"]: + seconds *= 1 - ITEMS[station]["speed"] if action["skill"] == "smithing": seconds *= 1 - 0.08 * building_level(world, "smithy") seconds *= 0.9 if fitted(world, "bellows") else 1 @@ -3990,7 +4008,8 @@ HERB_TINTS = {"guam_leaf": "#7fbf5a", "marrentill": "#e7c84a", "tarromin": "#d05 PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "visored-helm", "legs": "leg-armor", "body": "breastplate"} TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole", - "hunting knife": "bowie-knife", "hammer": "claw-hammer"} + "hunting knife": "bowie-knife", "hammer": "claw-hammer", "chisel": "chisel", "gem drill": "drill", + "cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears"} LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"} CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"} FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower", diff --git a/static/app.js b/static/app.js index bcbf497..62ae17f 100644 --- a/static/app.js +++ b/static/app.js @@ -523,16 +523,18 @@ function skillGear(skill) { : `No potion`} ${others ? `
${others}
` : ""}`); } - if (`tool_${skill}` in me.equip) { - 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) => { + for (const [kind, none] of [["tool", "No tool"], ["station", "No station"]]) { + const slot = `${kind}_${skill}`; + if (!(slot in me.equip)) continue; + const id = me.equip[slot], it = id && item(id); + const others = Object.keys(me.bank).filter((x) => item(x)[kind] === 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` : `${noTool}`}
- ${others || it ? `
${others}${it ? `` : ""}
` : ""}
`); + ${it ? it.icon : glyph(kind === "tool" ? "toolbox" : "anvil", "#98a1b4")} +
${it ? `${esc(it.name)}${Math.round(it.speed * 100)}% faster` : `${none}`}
+ ${others || it ? `
${others}${it ? `` : ""}
` : ""}`); } return cards.length ? `
${cards.join("")}
` : ""; } @@ -744,7 +746,7 @@ function itemStats(id) { if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 })); 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.tool || it.station) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool || it.station).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]}`); @@ -927,7 +929,7 @@ function equipRow(id, it) { } function equipLabel(it) { - if (it.tool) return `Use for ${skillOf(it.tool).name.toLowerCase()} (${Math.round(it.speed * 100)}% faster)`; + if (it.tool || it.station) return `Use for ${skillOf(it.tool || it.station).name.toLowerCase()} (${Math.round(it.speed * 100)}% faster)`; return "Equip"; } @@ -1432,9 +1434,9 @@ function characterSheet(uid) { const level = p.levels[s.id], xp = p.xp[s.id] || 0; const floor = C.xp_table[level], ceil = C.xp_table[level + 1]; const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100; - const tool = p.equip[`tool_${s.id}`]; + const gear = [p.equip[`tool_${s.id}`], p.equip[`station_${s.id}`]].filter(Boolean); const marks = (cls.fast.includes(s.id) ? `${cls.icon}` : "") - + (tool ? `${item(tool).icon}` : ""); + + gear.map((id) => `${item(id).icon}`).join(""); return `<${mine ? `button data-nav="skill:${s.id}"` : "div"} class="sheet-skill"> ${s.icon} ${s.name}${marks}${level} @@ -1586,12 +1588,17 @@ function equipmentPanel(p, mine, tools = true) { } // Tools: one small slot per gathering skill. +// Gathering tools in a row; under them each crafting skill's tool, with its station below it. function toolSlots(p) { - return `
${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => { - const id = p.equip[`tool_${s.id}`], it = id && item(id); - return ` + const slot = (kind, s) => { + const id = p.equip[`${kind}_${s.id}`], it = id && item(id); + return ` ${it ? it.icon : s.icon}`; - }).join("")}
`; + }; + const gathering = C.skills.filter((s) => `tool_${s.id}` in p.equip && !(`station_${s.id}` in p.equip)); + const crafting = C.skills.filter((s) => `station_${s.id}` in p.equip); + return `
${gathering.map((s) => slot("tool", s)).join("")}
+
${crafting.map((s) => slot("tool", s) + slot("station", s)).join("")}
`; } // --- registration --------------------------------------------------------- diff --git a/static/icons.svg b/static/icons.svg index a326cca..b3b25e3 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -36,6 +36,7 @@ + @@ -59,6 +60,7 @@ + @@ -117,11 +119,14 @@ + + + @@ -154,6 +159,7 @@ + @@ -181,6 +187,7 @@ + diff --git a/static/styles.css b/static/styles.css index 1340786..4e46ab6 100644 --- a/static/styles.css +++ b/static/styles.css @@ -506,6 +506,7 @@ button.sheet-skill:hover { background: var(--panel-2); } .worn-head .ico .gi { width: 22px; height: 22px; } .worn-head .btn { margin-left: auto; } .tool-slots { display: flex; flex-wrap: wrap; gap: 8px; } +.tool-grid { display: grid; grid-auto-flow: column; grid-template-rows: auto auto; justify-content: start; gap: 8px; margin-top: 12px; } @media (max-width: 520px) { .doll-wrap { justify-content: center; } .doll-side { flex-basis: 100%; } } /* Swapping gear from the doll */ @@ -572,7 +573,7 @@ button.sheet-skill:hover { background: var(--panel-2); } @media (max-width: 520px) { .doll-col { justify-items: center; } } /* A skill's potion and tool, side by side */ -.gear-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 14px; margin-bottom: 14px; } +.gear-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); gap: 14px; margin-bottom: 14px; } .gear-row .panel { margin: 0; } .gear-card { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 14px; } .gear-card > .doll-slot { cursor: default; flex: none; } diff --git a/tests/test_game.py b/tests/test_game.py index 944a668..36f0a55 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1770,8 +1770,31 @@ class StationAndStorageTest(unittest.TestCase): 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.bob["equip"]["station_smithing"] = "oak_anvil_stand" self.assertAlmostEqual(game.action_time(self.world, self.bob, smelt), bare * 0.7) + self.bob["equip"]["tool_smithing"] = "copper_chisel" + self.assertAlmostEqual(game.action_time(self.world, self.bob, smelt), bare * 0.7 * 0.85) + + def test_every_crafting_skill_has_a_tool_and_a_station(self): + for skill in game.CRAFTING: + self.assertIn(f"tool_{skill}", game.EQUIP_SLOTS) + self.assertIn(f"station_{skill}", game.EQUIP_SLOTS) + self.assertEqual(game.ACTION_BY_ID["smith_iron_mortar_and_pestle"]["inputs"], {"iron_bar": 2, "oak_logs": 1}) + self.assertEqual(game.ACTION_BY_ID["make_oak_workbench"]["skill"], "construction") + + def test_a_workbench_doesnt_speed_the_building_site(self): + labour = game.ACTION_BY_ID["labour"] + bare = game.action_time(self.world, self.bob, labour) + self.bob["equip"]["station_construction"] = "magic_workbench" + self.assertEqual(game.action_time(self.world, self.bob, labour), bare) + + def test_stations_move_out_of_the_tool_slot(self): + self.world["version"] = game.WORLD_VERSION - 1 + self.bob["equip"]["tool_smithing"] = "oak_anvil_stand" + del self.bob["equip"]["station_smithing"] + game.migrate(self.world, T0) + self.assertEqual((self.bob["equip"]["tool_smithing"], self.bob["equip"]["station_smithing"]), + (None, "oak_anvil_stand")) 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)