Rings of precision become rings of haste

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 20:26:54 +01:00
parent 6883b707ad
commit 5facec9ee8
4 changed files with 43 additions and 12 deletions

View file

@ -48,7 +48,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
diamond), and jewellers prospect spare ore for more (5 ore, 15%). Bands
are copper, then silver and gold, which the jeweller mines and smelts
without a smithy. Past quartz each piece needs a shard from that tier's
area. Rings for offence (accuracy, crit, power), amulets for defence (max
area. Rings for offence (swing speed, crit, power), amulets for defence (max
HP, ward, regen), trinkets for the rest (XP, double gathering,
masterwork).
- **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril,

33
game.py
View file

@ -221,7 +221,7 @@ PROSPECT = ["stone", "iron_ore", "coal", "mithril_ore", "adamantite_ore", "runit
# 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"),
("haste", "ring", "ring of haste", "haste", [5, 7, 8, 9, 10, 11], 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"),
@ -231,9 +231,9 @@ JEWELLERY = [
("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.
# How each jewellery bonus reads: name, unit, glyph, tint (and a sign, if it takes away).
JEWEL_STATS = {
"accuracy": {"name": "Accuracy", "unit": "%", "glyph": "archery-target", "tint": "#e0b04a"},
"haste": {"name": "Swing time", "unit": "%", "glyph": "stopwatch", "tint": "#9fb3c8", "sign": "-"},
"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"},
@ -1033,7 +1033,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 19
WORLD_VERSION = 20
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1187,9 +1187,29 @@ def migrate(world: dict, now: float | None = None) -> dict:
for player in world["players"].values():
player.setdefault("pinned", [])
world["version"] = 19
if world["version"] < 20:
_precision_to_haste(world)
world["version"] = 20
return world
def _precision_to_haste(world: dict) -> None:
"""Rings of precision became rings of haste: same gem, same slot."""
rename = lambda item: item.replace("_precision", "_haste") if item and item.endswith("_precision") else item
swap = lambda items: {rename(item): qty for item, qty in items.items()}
world["city"]["stockpile"] = swap(world["city"]["stockpile"])
for player in world["players"].values():
player["bank"] = swap(player["bank"])
player["equip"] = {slot: rename(item) for slot, item in player["equip"].items()}
action = player["action"]
if action:
for key in ("id", "resume"):
if key in action:
action[key] = rename(action[key])
for order in world.get("orders", []):
order["item"], order["action"] = rename(order["item"]), rename(order["action"])
# The combat rework: the old area monsters became races, their trophies carry over by
# weapon line and tier, dragonhide became the dragons' reagents, and the leathers past
# bear are hunted now.
@ -1578,8 +1598,9 @@ def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None)
damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
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"] * (1 + gear_bonus(player, "accuracy") / 100),
return {"style": style_id, "damage": style["damage"],
"interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2),
"accuracy": (10 + 2 * power) * style["accuracy"],
"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,

View file

@ -670,7 +670,7 @@ function animateFight(now) {
// A piece of jewellery's bonus, e.g. "+8% accuracy".
function jewelText(it) {
const st = C.jewel_stats[it.jewel];
return `+${it[it.jewel]}${st.unit} ${st.name.toLowerCase()}`;
return `${st.sign || "+"}${it[it.jewel]}${st.unit} ${st.name.toLowerCase()}`;
}
// Power, armour and ward, and the armour's weight.
@ -703,7 +703,7 @@ function gearChips(it, who = S.me) {
cell(glyph("magic-shield", "#a77be8"), it.ward, `+${it.ward} ward: less damage from magic`)];
} else if (it.jewel) {
const st = C.jewel_stats[it.jewel];
row = [cell(glyph(st.glyph, st.tint), `+${it[it.jewel]}${st.unit}`, jewelText(it)), "<span></span>", "<span></span>"];
row = [cell(glyph(st.glyph, st.tint), `${st.sign || "+"}${it[it.jewel]}${st.unit}`, jewelText(it)), "<span></span>", "<span></span>"];
}
let req = "", bonus = "";
if (it.req) {

View file

@ -1650,7 +1650,7 @@ class JewelcraftingTest(unittest.TestCase):
self.ann = self.world["players"]["ann"]
def test_recipes_take_a_band_a_gem_and_past_quartz_a_shard(self):
self.assertEqual(game.ACTION_BY_ID["jc_quartz_precision"]["inputs"], {"copper_bar": 1, "quartz": 1})
self.assertEqual(game.ACTION_BY_ID["jc_quartz_haste"]["inputs"], {"copper_bar": 1, "quartz": 1})
self.assertEqual(game.ACTION_BY_ID["jc_garnet_vigour"]["inputs"],
{"silver_bar": 2, "garnet": 1, "watchtower_shard": 2})
self.assertEqual(game.ACTION_BY_ID["jc_diamond_charm"]["inputs"],
@ -1679,9 +1679,9 @@ class JewelcraftingTest(unittest.TestCase):
p["xp"]["combat"] = game.XP_TABLE[20]
before = game.fighter(p, game.NO_BLESSING, {})
hp = game.max_hp(p)
p["equip"].update(ring="garnet_precision", amulet="garnet_vigour", trinket="garnet_hourglass")
p["equip"].update(ring="garnet_haste", amulet="garnet_vigour", trinket="garnet_hourglass")
after = game.fighter(p, game.NO_BLESSING, {})
self.assertAlmostEqual(after["accuracy"], before["accuracy"] * 1.08)
self.assertAlmostEqual(after["interval"], round(before["interval"] * 0.93, 2))
self.assertEqual(game.max_hp(p), hp + 10)
game.gain_xp(self.world, p, "mining", 100, T0)
self.assertAlmostEqual(p["xp"]["mining"], 103)
@ -1707,3 +1707,13 @@ class JewelcraftingTest(unittest.TestCase):
self.assertEqual(self.ann["xp"]["jewelcrafting"], 0)
self.assertEqual({slot: self.ann["equip"][slot] for slot in ("ring", "amulet", "trinket")},
{"ring": None, "amulet": None, "trinket": None})
def test_rings_of_precision_became_haste(self):
self.world["version"] = 19
self.ann["bank"] = {"garnet_precision": 2, "garnet": 1}
self.ann["equip"]["ring"] = "quartz_precision"
self.ann["action"] = {"id": "jc_garnet_precision", "start": T0}
game.migrate(self.world, T0)
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")