Jewelcrafting: gems, rings, amulets, trinkets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 19:59:45 +01:00
parent 0d33e09799
commit fb237cb686
5 changed files with 266 additions and 26 deletions

160
game.py
View file

@ -198,6 +198,63 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier,
"slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]],
}
# Jewelcrafting: a gem from each tier's rock, set in a band of copper, silver or gold.
# Silver and gold are soft metals the jeweller smelts, no smithy needed. Past the first
# tier, every piece also needs a shard from that tier's expedition area.
# gem id, name, value, tint, glyph
GEMS = [
("quartz", "Quartz", 8, "#e8e4f0", "crystal-bars"),
("garnet", "Garnet", 20, "#c8405a", "rupee"),
("amethyst", "Amethyst", 40, "#a070e0", "amethyst"),
("sapphire", "Sapphire", 70, "#4f7ff0", "saphir"),
("emerald", "Emerald", 110, "#3fc070", "emerald"),
("diamond", "Diamond", 160, "#d8f4ff", "diamond-hard"),
]
BANDS = ["copper_bar", "silver_bar", "silver_bar", "gold_bar", "gold_bar", "gold_bar"]
GEM_ODDS = 0.02 # a gem with each rock mined
PROSPECT_ORE = 5 # ore cracked open per prospecting action...
PROSPECT_ODDS = 0.15 # ...for this chance at its gem
# The gem each rock turns up when mined, and what's cracked open to prospect for each gem.
GEM_ROCKS = {"mine_stone": "quartz", "mine_copper": "quartz", "mine_iron": "garnet", "mine_coal": "amethyst",
"mine_mithril": "sapphire", "mine_adamantite": "emerald", "mine_runite": "diamond"}
PROSPECT = ["stone", "iron_ore", "coal", "mithril_ore", "adamantite_ore", "runite_ore"]
# Rings for offence, amulets for defence, trinkets for everything else.
# key, slot, noun, stat, bonus by tier, bands, gems, shards, level above the tier, glyph
JEWELLERY = [
("precision", "ring", "ring of precision", "accuracy", [4, 8, 12, 16, 20, 25], 1, 1, 1, 2, "diamond-ring"),
("fury", "ring", "ring of fury", "crit", [2, 3, 5, 6, 8, 10], 1, 1, 1, 2, "big-diamond-ring"),
("might", "ring", "ring of might", "power", [2, 4, 6, 9, 12, 15], 1, 1, 1, 2, "swirl-ring"),
("vigour", "amulet", "amulet of vigour", "hp", [5, 10, 16, 23, 31, 40], 2, 1, 2, 8, "heart-necklace"),
("warding", "amulet", "amulet of warding", "ward", [3, 5, 7, 10, 12, 15], 2, 1, 2, 8, "gem-pendant"),
("mending", "amulet", "amulet of mending", "regen", [10, 20, 30, 40, 50, 60], 2, 1, 2, 8, "tribal-pendant"),
("hourglass", "trinket", "hourglass", "xp", [2, 3, 5, 6, 8, 10], 1, 2, 1, 5, "hourglass"),
("charm", "trinket", "lucky charm", "double", [2, 3, 4, 5, 6, 8], 1, 2, 1, 5, "clover"),
("loupe", "trinket", "loupe", "masterwork", [2, 3, 4, 4, 5, 6], 1, 2, 1, 5, "magnifying-glass"),
]
# How each jewellery bonus reads: name, unit, glyph, tint.
JEWEL_STATS = {
"accuracy": {"name": "Accuracy", "unit": "%", "glyph": "archery-target", "tint": "#e0b04a"},
"crit": {"name": "Crit chance", "unit": "%", "glyph": "striking-diamonds", "tint": "#e05a5a"},
"power": {"name": "Power", "unit": "", "glyph": "biceps", "tint": "#e06a6a"},
"hp": {"name": "Max HP", "unit": "", "glyph": "hearts", "tint": "#e06a8a"},
"ward": {"name": "Ward", "unit": "", "glyph": "magic-shield", "tint": "#a77be8"},
"regen": {"name": "HP regen", "unit": "%", "glyph": "heart-plus", "tint": "#e06a8a"},
"xp": {"name": "XP", "unit": "%", "glyph": "sparkles", "tint": "#f0d060"},
"double": {"name": "Double gathering", "unit": "%", "glyph": "clover", "tint": "#5fae5a"},
"masterwork": {"name": "Masterwork", "unit": "%", "glyph": "anvil", "tint": "#d07a3c"},
}
ITEMS["silver_ore"] = {"name": "Silver ore", "icon": "⚪", "value": 6}
ITEMS["silver_bar"] = {"name": "Silver bar", "icon": "🥈", "value": 15, "tier": 1}
ITEMS["gold_ore"] = {"name": "Gold ore", "icon": "🟡", "value": 20}
ITEMS["gold_bar"] = {"name": "Gold bar", "icon": "🥇", "value": 45, "tier": 3}
for _tier, (_gem, _gem_name, _gem_value, _tint, _glyph) in enumerate(GEMS):
ITEMS[_gem] = {"name": _gem_name, "icon": "💎", "value": _gem_value, "tier": _tier}
for _key, _slot, _noun, _stat, _bonus, _bands, _gems, _shards, _offset, _piece_glyph in JEWELLERY:
ITEMS[f"{_gem}_{_key}"] = {
"name": f"{_gem_name} {_noun}", "icon": {"ring": "💍", "amulet": "📿", "trinket": "🔮"}[_slot],
"value": round((_bands * ITEMS[BANDS[_tier]]["value"] + _gems * _gem_value + _shards * 12 * _tier) * 1.3),
"slot": _slot, "tier": _tier, "jewel": _stat, _stat: _bonus[_tier],
**({"req": ["combat", EQUIP_LEVELS[_tier]]} if _slot != "trinket" else {})}
# Hardware: what the smith makes for builders, one kind per metal, used in
# fittings and in building levels in place of plain bars.
# id, singular, plural, bars used, pieces made
@ -343,7 +400,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"] + [f"tool_{skill}" for skill in TOOLS]
EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket"] + [f"tool_{skill}" for skill in TOOLS]
SKILLS = [
{"id": "mining", "name": "Mining", "icon": "⛏️", "group": "Gathering"},
@ -352,6 +409,7 @@ SKILLS = [
{"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"},
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
{"id": "jewelcrafting", "name": "Jewelcrafting", "icon": "💍", "group": "Crafting"},
{"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"},
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
@ -363,16 +421,17 @@ SKILLS = [
]
SKILL_IDS = [skill["id"] for skill in SKILLS]
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
CRAFTING = {"smithing", "construction", "cooking", "leatherworking", "herblore", "tailoring"}
CRAFTING = {"smithing", "jewelcrafting", "construction", "cooking", "leatherworking", "herblore", "tailoring"}
# A class is a whole trade, from gathering to the finished goods: its skills are
# 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own.
# Nothing is locked: every class can train every skill.
CLASSES = {
"miner": {
"name": "Miner", "icon": "⛏️", "fast": ["mining", "smithing"], "craft": ["smithing"],
"bonus": "Mining and smithing 20% faster",
"masterwork": "Masterwork: 10% double smithing crafts",
"name": "Miner", "icon": "⛏️", "fast": ["mining", "smithing", "jewelcrafting"],
"craft": ["smithing", "jewelcrafting"],
"bonus": "Mining, smithing and jewelcrafting 20% faster",
"masterwork": "Masterwork: 10% double smithing and jewelcrafting crafts",
"signature": "Prospector: 10% double ore",
"combat": "Warhammer: +15% damage",
},
@ -518,10 +577,11 @@ CLASS_STYLE = {"miner": "hammer", "builder": "sword", "homesteader": "staff", "r
CLASS_STYLE_BONUS = 1.15 # damage with your own trade's weapon
BLOCK_CHANCE = 0.15 # a shield turns aside this share of hits
# Each armour piece past copper needs reagents from its tier's expedition area.
# Each armour piece past copper needs reagents from its tier's expedition area, and so
# does each piece of jewellery past quartz (its shards).
REAGENT_COUNTS = {"helmet": 1, "legs": 2, "body": 3}
REAGENT_NOUNS = {"heavy": "plating", "medium": "lacing", "light": "thread"}
REAGENT_ODDS = 0.10 # each of the area's three reagents, from any of its races
REAGENT_NOUNS = {"heavy": "plating", "medium": "lacing", "light": "thread", "jewel": "shard"}
REAGENT_ODDS = 0.10 # each of the area's four reagents, from any of its races
TROPHY_ODDS = 0.03 # rare: higher-tier weapons are hunted for
BOSS_TROPHY_ODDS = 0.06 # each of the four lines, so a boss is worth the longer fight
@ -705,7 +765,7 @@ def reagent(weight: str, tier: int) -> str:
def _area_drops(tier: int, odds: float = REAGENT_ODDS) -> list[tuple]:
return [(reagent(weight, tier), 1, odds) for weight in WEIGHTS]
return [(reagent(kind, tier), 1, odds) for kind in REAGENT_NOUNS]
def _weapon_actions() -> list[dict]:
@ -729,6 +789,32 @@ def _weapon_actions() -> list[dict]:
return actions
def _jewel_actions() -> list[dict]:
"""Silver and gold, prospecting ore for gems, and every tier's jewellery."""
actions = [
_action("mine_silver", "mining", "Mine silver", 10, 35, 12, outputs={"silver_ore": 1}),
_action("mine_gold", "mining", "Mine gold", 40, 55, 36, outputs={"gold_ore": 1}),
]
for tier, (source, (gem, *_rest)) in enumerate(zip(PROSPECT, GEMS)):
actions.append(_action(f"prospect_{gem}", "jewelcrafting", f"Prospect {ITEMS[source]['name'].lower()}",
TIER_LEVELS[tier], 20 + 3 * tier, [8, 18, 28, 40, 54, 70][tier],
{source: PROSPECT_ORE}, finds=[(gem, 1, PROSPECT_ODDS)], group="Prospecting"))
actions += [
_action("jc_silver_bar", "jewelcrafting", "Silver bar", 10, 25, 12, {"silver_ore": 1, "logs": 1},
{"silver_bar": 1}, group="Bars"),
_action("jc_gold_bar", "jewelcrafting", "Gold bar", 40, 30, 32, {"gold_ore": 1, "coal": 1},
{"gold_bar": 1}, group="Bars"),
]
for tier, (gem, gem_name, *_rest) in enumerate(GEMS):
for key, _slot, noun, _stat, _bonus, bands, gems, shards, offset, _glyph in JEWELLERY:
inputs = {BANDS[tier]: bands, gem: gems, **({reagent("jewel", tier): shards} if tier else {})}
actions.append(_action(f"jc_{gem}_{key}", "jewelcrafting", f"{gem_name} {noun}", TIER_LEVELS[tier] + offset,
35 + 5 * (bands + gems) + 2 * tier,
round([15, 32, 52, 75, 100, 130][tier] * (bands + gems) / 2),
inputs, {f"{gem}_{key}": 1}, group=gem_name))
return actions
def _area_fights() -> list[dict]:
fights = []
for tier, (area, level, _prefix, races) in enumerate(AREAS, start=1):
@ -834,6 +920,8 @@ ACTIONS = [
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
]
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
for _id, _gem in GEM_ROCKS.items():
ACTION_BY_ID[_id]["finds"] = [(_gem, 1, GEM_ODDS)]
BUILDINGS = {
"houses": {
@ -945,7 +1033,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 17
WORLD_VERSION = 18
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1088,6 +1176,13 @@ def migrate(world: dict, now: float | None = None) -> dict:
world.setdefault("orders", [])
world.setdefault("next_order_id", 1)
world["version"] = 17
if world["version"] < 18:
# Jewelcrafting: a new skill and three new slots.
for player in world["players"].values():
player["xp"].setdefault("jewelcrafting", 0)
for slot in ("ring", "amulet", "trinket"):
player["equip"].setdefault(slot, None)
world["version"] = 18
return world
@ -1430,6 +1525,11 @@ def defence_bonus(player: dict) -> int:
return round((armour(player) + magic_ward(player)) * 0.4)
def gear_bonus(player: dict, stat: str) -> float:
"""A bonus that comes from what you're wearing: jewellery's accuracy, crit, HP and so on."""
return sum(ITEMS[item].get(stat, 0) for item in player["equip"].values() if item)
def combat_power(player: dict) -> int:
power = skill_level(player, "combat") + defence_bonus(player)
for item in player["equip"].values():
@ -1439,7 +1539,7 @@ def combat_power(player: dict) -> int:
def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat")
return 40 + 4 * skill_level(player, "combat") + int(gear_bonus(player, "hp"))
def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
@ -1447,6 +1547,7 @@ def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
if now > player["hp_at"]:
top = max_hp(player)
boost *= 2 if fitted(world, "enchanted_hearths") else 1
boost *= 1 + gear_bonus(player, "regen") / 100
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now
@ -1473,8 +1574,9 @@ def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None)
lands = affinity(enemy, style["damage"]) if enemy else 1
taken = blessed["taken"] * (1 - brew.get("reduce", 0))
return {"style": style_id, "damage": style["damage"], "interval": style["interval"],
"accuracy": (10 + 2 * power) * style["accuracy"],
"max_hit": max(2, round((2 + power / 3) * damage * lands)), "crit": style["crit"],
"accuracy": (10 + 2 * power) * style["accuracy"] * (1 + gear_bonus(player, "accuracy") / 100),
"max_hit": max(2, round((2 + power / 3) * damage * lands)),
"crit": style["crit"] + gear_bonus(player, "crit") / 100,
"first": style.get("first", False), "affinity": lands,
"block": BLOCK_CHANCE if player["equip"].get("shield") else 0,
"evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)),
@ -1839,7 +1941,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
cls = CLASSES[player["cls"]]
lucky = bool(action["outputs"]) and (
action["skill"] in cls["craft"] or (player["cls"] == "miner" and action["skill"] == "mining")
) and rng.random() < LUCKY_CHANCE
) and rng.random() < LUCKY_CHANCE + gear_bonus(player, "masterwork") / 100
outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()}
if lucky:
summary["lucky"] += 1
@ -1847,11 +1949,19 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
for item, qty in action["outputs"].items():
outputs[item] += qty
summary["lucky"] += 1
if action["skill"] in GATHERING and rng.random() < gear_bonus(player, "double") / 100:
for item, qty in action["outputs"].items():
outputs[item] += qty
summary["lucky"] += 1
mods = skill_mods(world, action)
if mods["yield"] != 1:
for item, qty in list(outputs.items()):
exact = qty * mods["yield"]
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
for item, qty, odds in action.get("finds", []):
# A gem in the rock: a masterwork (a prospector's double ore) turns up two.
if rng.random() < odds:
outputs[item] = outputs.get(item, 0) + qty * (2 if lucky else 1)
xp *= mods["xp"]
if "enemy" in action:
@ -1959,6 +2069,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summary: dict | None = None) -> None:
before = skill_level(player, skill)
xp *= 1 + gear_bonus(player, "xp") / 100
player["xp"][skill] += xp
after = skill_level(player, skill)
if summary is not None:
@ -2079,7 +2190,7 @@ def _order_sources() -> tuple[dict, dict]:
if action["skill"] in CRAFTING:
craft.setdefault(item, action["id"])
elif action["skill"] in GATHERING:
for output in action["outputs"]:
for output in [*action["outputs"], *(item for item, _qty, _odds in action.get("finds", []))]:
gather.setdefault(output, action["id"])
return craft, gather
@ -3169,6 +3280,9 @@ FITTINGS = [
for _weapon in _weapon_actions():
ACTIONS.append(_weapon)
ACTION_BY_ID[_weapon["id"]] = _weapon
for _jewel in _jewel_actions():
ACTIONS.append(_jewel)
ACTION_BY_ID[_jewel["id"]] = _jewel
FITTING_BY_ID = {}
for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS:
FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect}
@ -3748,7 +3862,7 @@ CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"}
FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower",
"starbloom": "spiral-bloom", "spiritvine": "vine-flower"}
CLOTH_TINTS = ["#d8d0b8", "#f0f0f0", "#e8c8e0", "#a8c8f0", "#f0e080", "#9be0c8"]
REAGENT_GLYPHS = {"heavy": "chain-mail", "medium": "scale-mail", "light": "yarn"}
REAGENT_GLYPHS = {"heavy": "chain-mail", "medium": "scale-mail", "light": "yarn", "jewel": "crystal-shine"}
WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"}
HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"]
GLYPHS = {
@ -3759,6 +3873,8 @@ GLYPHS = {
"bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"),
"dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"),
"coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"),
"silver_ore": ("ore", "#dfe4ec"), "silver_bar": ("metal-bar", "#dfe4ec"),
"gold_ore": ("ore", "#f0c040"), "gold_bar": ("gold-bar", "#f0c040"),
"thatch": ("high-grass", "#d8c070"), "flax": ("flax", "#8fb0d8"), "rope": ("rope-coil", "#c9a66b"),
"hide_glue": ("honey-jar", "#c08a40"), "provisions": ("knapsack", "#a08050"),
"raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"),
@ -3770,6 +3886,7 @@ GLYPHS = {
SKILL_GLYPHS = {
"mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"),
"hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"),
"jewelcrafting": ("gems", "#e070c0"),
"construction": ("hammer-nails", "#d1a46a"),
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
@ -3819,7 +3936,7 @@ UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "mag
"archery-target", "dodging", "shield-reflect",
"cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "round-bottom-flask", "visored-helm", "breastplate",
"leg-armor", "pointy-sword", "round-shield",
*(kind["glyph"] for kind in DAMAGE_TYPES.values())]
*(kind["glyph"] for kind in DAMAGE_TYPES.values()), *(stat["glyph"] for stat in JEWEL_STATS.values())]
def _assign_glyphs() -> None:
@ -3858,6 +3975,10 @@ 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 gem, _name, _value, tint, glyph in GEMS:
ITEMS[gem].update(glyph=glyph, tint=tint)
for key, *_rest, piece_glyph in JEWELLERY:
ITEMS[f"{gem}_{key}"].update(glyph=piece_glyph, tint=tint)
flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
"bubbling-flask", "bubbling-flask"]
for potion, *_rest, skill, effect in POTIONS:
@ -3877,6 +3998,9 @@ def _assign_glyphs() -> None:
elif action["outputs"]:
first = ITEMS[next(iter(action["outputs"]))]
action["glyph"], action["tint"] = first["glyph"], first["tint"]
elif action.get("finds"):
first = ITEMS[action["finds"][0][0]]
action["glyph"], action["tint"] = first["glyph"], first["tint"]
elif action.get("special") in ("bury", "offer"):
bone = ITEMS[next(iter(action["inputs"]))]
action["glyph"] = "tombstone" if action["special"] == "bury" else "candle-holder"
@ -3913,6 +4037,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, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,
}