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 diamond), and jewellers prospect spare ore for more (5 ore, 15%). Bands
are copper, then silver and gold, which the jeweller mines and smelts 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 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, HP, ward, regen), trinkets for the rest (XP, double gathering,
masterwork). masterwork).
- **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril, - **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. # 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 # key, slot, noun, stat, bonus by tier, bands, gems, shards, level above the tier, glyph
JEWELLERY = [ 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"), ("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"), ("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"), ("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"), ("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"), ("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 = { 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"}, "crit": {"name": "Crit chance", "unit": "%", "glyph": "striking-diamonds", "tint": "#e05a5a"},
"power": {"name": "Power", "unit": "", "glyph": "biceps", "tint": "#e06a6a"}, "power": {"name": "Power", "unit": "", "glyph": "biceps", "tint": "#e06a6a"},
"hp": {"name": "Max HP", "unit": "", "glyph": "hearts", "tint": "#e06a8a"}, "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 ---------------------------------------------------------------
WORLD_VERSION = 19 WORLD_VERSION = 20
def new_world(now: float, speed: float = 1.0) -> dict: 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(): for player in world["players"].values():
player.setdefault("pinned", []) player.setdefault("pinned", [])
world["version"] = 19 world["version"] = 19
if world["version"] < 20:
_precision_to_haste(world)
world["version"] = 20
return world 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 # 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 # weapon line and tier, dragonhide became the dragons' reagents, and the leathers past
# bear are hunted now. # 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) 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 lands = affinity(enemy, style["damage"]) if enemy else 1
taken = blessed["taken"] * (1 - brew.get("reduce", 0)) taken = blessed["taken"] * (1 - brew.get("reduce", 0))
return {"style": style_id, "damage": style["damage"], "interval": style["interval"], return {"style": style_id, "damage": style["damage"],
"accuracy": (10 + 2 * power) * style["accuracy"] * (1 + gear_bonus(player, "accuracy") / 100), "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)), "max_hit": max(2, round((2 + power / 3) * damage * lands)),
"crit": style["crit"] + gear_bonus(player, "crit") / 100, "crit": style["crit"] + gear_bonus(player, "crit") / 100,
"first": style.get("first", False), "affinity": lands, "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". // A piece of jewellery's bonus, e.g. "+8% accuracy".
function jewelText(it) { function jewelText(it) {
const st = C.jewel_stats[it.jewel]; 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. // 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`)]; cell(glyph("magic-shield", "#a77be8"), it.ward, `+${it.ward} ward: less damage from magic`)];
} else if (it.jewel) { } else if (it.jewel) {
const st = C.jewel_stats[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 = ""; let req = "", bonus = "";
if (it.req) { if (it.req) {

View file

@ -1650,7 +1650,7 @@ class JewelcraftingTest(unittest.TestCase):
self.ann = self.world["players"]["ann"] self.ann = self.world["players"]["ann"]
def test_recipes_take_a_band_a_gem_and_past_quartz_a_shard(self): 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"], self.assertEqual(game.ACTION_BY_ID["jc_garnet_vigour"]["inputs"],
{"silver_bar": 2, "garnet": 1, "watchtower_shard": 2}) {"silver_bar": 2, "garnet": 1, "watchtower_shard": 2})
self.assertEqual(game.ACTION_BY_ID["jc_diamond_charm"]["inputs"], 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] p["xp"]["combat"] = game.XP_TABLE[20]
before = game.fighter(p, game.NO_BLESSING, {}) before = game.fighter(p, game.NO_BLESSING, {})
hp = game.max_hp(p) 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, {}) 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) self.assertEqual(game.max_hp(p), hp + 10)
game.gain_xp(self.world, p, "mining", 100, T0) game.gain_xp(self.world, p, "mining", 100, T0)
self.assertAlmostEqual(p["xp"]["mining"], 103) self.assertAlmostEqual(p["xp"]["mining"], 103)
@ -1707,3 +1707,13 @@ class JewelcraftingTest(unittest.TestCase):
self.assertEqual(self.ann["xp"]["jewelcrafting"], 0) self.assertEqual(self.ann["xp"]["jewelcrafting"], 0)
self.assertEqual({slot: self.ann["equip"][slot] for slot in ("ring", "amulet", "trinket")}, self.assertEqual({slot: self.ann["equip"][slot] for slot in ("ring", "amulet", "trinket")},
{"ring": None, "amulet": None, "trinket": None}) {"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")