diff --git a/app.py b/app.py index 85cc76d..24dc624 100644 --- a/app.py +++ b/app.py @@ -125,6 +125,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["rent_cost"] = game.rent_cost(player) view["caravan"] = game.caravan_view(world, player, now) view["titles"] = game.titles(player) + view["achievements"] = game.achievements(player) view["shown"] = game.look_view(player) if player["action"]: action, fight = game.growing(player), game.current_fight(world, player) diff --git a/game.py b/game.py index 953906f..29450a0 100644 --- a/game.py +++ b/game.py @@ -1524,7 +1524,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 30 +WORLD_VERSION = 31 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1764,6 +1764,12 @@ def migrate(world: dict, now: float | None = None) -> dict: for player in world["players"].values(): player.setdefault("patterns", []) world["version"] = 30 + if world["version"] < 31: + # Uniques ever found, for achievements: whatever's held now. + for player in world["players"].values(): + held = [*player["bank"], *player["equip"].values()] + player.setdefault("found", [item for item in UNIQUES if item in held]) + world["version"] = 31 return world @@ -2020,7 +2026,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: "hp": 44, "hp_at": now, "action": None, "stopped": None, "last_active": now, "overflow": "city", - "perks": {"bank_slot": 0}, "manual": [], "patterns": [], "eat_below": EAT_BELOW, + "perks": {"bank_slot": 0}, "manual": [], "patterns": [], "found": [], "eat_below": EAT_BELOW, "rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK), "garrison": False, "donated": 0, @@ -2082,6 +2088,8 @@ def city_uses(item: str) -> list[str]: def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary: dict) -> int: """Store what fits; the rest goes to the city if it has a use, else is sold. Returns qty kept.""" + if ITEMS[item].get("unique") and item not in player["found"]: + player["found"].append(item) # for achievements: once found, always found keep = min(qty, bank_room(world, player, item)) if keep: player["bank"][item] = player["bank"].get(item, 0) + keep @@ -5626,6 +5634,65 @@ DRILL_SERGEANT_POINTS = 30 # boosted drilling per hire: doubled points and DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat +# --- achievements ---------------------------------------------------------------- +# What's left to unlock. Read off what you already have, never a source of anything: +# patterns learned, uniques found, titles, looks, your home. + +def _got(name: str, done, glyph: str, tint: str, tip: str = "", colour: str | None = None) -> dict: + return {"name": name, "done": bool(done), "glyph": glyph, "tint": tint, "tip": tip, **({"colour": colour} if colour else {})} + + +def achievements(player: dict) -> list[dict]: + """Sections of cards, each card a set of things to collect.""" + skill_name = {s["id"]: s["name"] for s in SKILLS} + dungeons = sorted(DUNGEON_LOOT, key=lambda key: DUNGEONS[key]["level"]) + + patterns = [] + for key in dungeons: + cards = [] + for pattern in ACTION_BY_ID[key]["loot"]["patterns"]: + spec, made = ITEMS[pattern], ITEMS[ITEMS[pattern]["pattern"]] + held = pattern not in player["patterns"] and player["bank"].get(pattern) + tip = f"{skill_name[spec['skill']]} {spec['level']}" + (" ยท ๐Ÿ“œ in bank" if held else "") + cards.append({**_got(made["name"], pattern in player["patterns"], made["glyph"], made["tint"], tip), "held": bool(held)}) + patterns.append({"name": DUNGEONS[key]["name"], "entries": cards}) + found = player.get("found", []) + uniques = [_got(ITEMS[unique]["name"], unique in found, ITEMS[unique]["glyph"], ITEMS[unique]["tint"], + DUNGEONS[key]["name"]) for key in dungeons for unique in [DUNGEON_LOOT[key][2]]] + + def level_title(need: int, rank: str) -> list[dict]: + return [_got(f"{rank} {noun}", skill_level(player, skill) >= need, *SKILL_GLYPHS[skill], + f"{skill_name[skill]} {skill_level(player, skill)} / {need}") for skill, noun in SKILL_TITLES.items()] + deeds = {"defended": ("checked-shield", "#d4dae2", "Raids won on guard"), "dragons": ("dragon-head", "#e05a5a", "Dragons slain on guard"), + "bosses": ("crossed-swords", "#e0b04a", "Bosses beaten")} + feats = [_got(title, skill_level(player, skill) >= need, *SKILL_GLYPHS[skill], f"{skill_name[skill]} {skill_level(player, skill)} / {need}") + for skill, need, title in SKILL_EXTRA_TITLES] + feats += [_got(title, player["deeds"].get(key, 0) >= need, *deeds[key][:2], f"{deeds[key][2]} {player['deeds'].get(key, 0)} / {need}") + for key, need, title in DEED_TITLES] + feats.append(_got("Patron of the City", player["donated"] >= 100_000, "sparkles", "#b98ce8", + f"Given {player['donated']:,} / 100,000")) + + def bought(kind: str) -> list[dict]: + return [_got(spec["value"] if kind == "title" else spec["name"], key in player["owned"], + "scroll-unfurled" if kind == "title" else spec.get("value", ""), "#e0b04a", + f"Caravan ยท {spec['price']:,} gold", spec["value"] if kind == "colour" else None) + for key, spec in COSMETICS.items() if spec["kind"] == kind and spec.get("price")] + bosses = [_got(spec["name"], key in player["owned"], ACTION_BY_ID[spec["boss"]]["glyph"], ACTION_BY_ID[spec["boss"]]["tint"], + ACTION_BY_ID[spec["boss"]]["name"]) for key, spec in COSMETICS.items() if spec.get("boss")] + homes = [_got(name, player.get("home", 0) >= level, "castle", "#d4dae2", f"{cost:,} gold") + for level, (name, cost) in enumerate(HOMES) if level] + + return [ + {"name": "Masterwork patterns", "cards": patterns}, + {"name": "Dungeon loot", "cards": [{"name": "Uniques", "entries": uniques}]}, + {"name": "Titles", "cards": [{"name": "Masters", "entries": level_title(50, "Master")}, + {"name": "Grandmasters", "entries": level_title(99, "Grandmaster")}, + {"name": "Deeds", "entries": feats}, {"name": "Caravan titles", "entries": bought("title")}]}, + {"name": "Looks", "cards": [{"name": "Boss emblems", "entries": bosses}, {"name": "Caravan emblems", "entries": bought("emblem")}, + {"name": "Dyes", "entries": bought("colour")}, {"name": "Home", "entries": homes}]}, + ] + + def drill_per_action(player: dict) -> float: return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20)) * ware(player, "drill", "combat") @@ -6014,6 +6081,8 @@ PATCH_NOTES = [ "lens, Wyrmscale quiver. Abilities it changes get a โœฆ."), ("new", "Choose when you eat between fights: below 25 / 50 / 75% of your HP, always, or never (the food slot, " "under \"Eat below\"). Default 50%: above it you just rest, so a scratch doesn't cost food."), + ("new", "๐Ÿ† Achievements (under You): 127 to collect. Masterwork patterns per dungeon (7 each), the 7 uniques, titles, " + "boss emblems, caravan looks, home. Tap an icon for what it is and how to get it. Nothing is awarded there, it only keeps track."), ("new", "Tapping an enemy or ally switches at once, and your next press goes to them: tap, then interrupt, " "without waiting."), ("new", "HP bars show what was just lost, with each fighter's last hit or heal by their HP."), diff --git a/static/app.js b/static/app.js index 1518f40..7afd17b 100644 --- a/static/app.js +++ b/static/app.js @@ -13,6 +13,7 @@ let skew = 0; // server clock minus client clock, seconds let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let selected = null; // bank item let dollPick = null; // equipment slot shown on the character sheet +let achPick = null; // achievement chip spelled out: "section.card.entry" let orderTab = "take"; // work orders: for you, all open, yours let draft = null; // a work order being written let seenOrders = null; // order ids already known, so only new ones toast @@ -374,7 +375,8 @@ function sidebar() { ${nav("notes", "๐Ÿ“ฐ", "Patch notes", "", notesUnread() ? `new` : "", notesUnread() ? `What's new: ${C.patch_notes[0].title}` : "")} ${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`, "", `Combat level ${me.levels.combat}`)} - ${nav("bank", "๐Ÿฆ", "Bank", `${me.slots_used}/${me.slots}`, "", `${me.slots_used} of ${me.slots} bank slots used`)}`; + ${nav("bank", "๐Ÿฆ", "Bank", `${me.slots_used}/${me.slots}`, "", `${me.slots_used} of ${me.slots} bank slots used`)} + ${achievementsNav(me)}`; let group = null; for (const skill of C.skills) { if (skill.class && skill.class !== me.cls) continue; @@ -462,7 +464,7 @@ function pageHtml() { } if (page.startsWith("character:")) return characterSheet(page.slice(10)); if (page === "character") return characterSheet(S.uid); - return ({ town, council, orders, board, roster, notes, bank }[page] || town)(); + return ({ town, council, orders, board, roster, notes, bank, achievements }[page] || town)(); } // Recipe input as "have / need"; without `have`, just the quantity. @@ -2328,6 +2330,40 @@ function loop() { requestAnimationFrame(loop); } +// --- achievements ---------------------------------------------------------- +// A record of what's left to unlock, read off what you already have. Tap a chip for what it is. + +const tally = (entries) => [entries.filter((e) => e.done).length, entries.length]; + +function achievementsNav(me) { + const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries))); + return nav("achievements", "๐Ÿ†", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`); +} + +function achievements() { + const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries)); + const [have, need] = tally(all); + return `

Achievements ${have} / ${need}

+
` + + sections.map((s, si) => { + const [sh, sn] = tally(s.cards.flatMap((c) => c.entries)); + return `

${esc(s.name)} ${sh} / ${sn}

+
${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}
`; + }).join(""); +} + +function achCard(card, key) { + const [have, need] = tally(card.entries); + const pick = achPick?.startsWith(key + ".") ? card.entries[Number(achPick.split(".")[2])] : null; + const icon = (e) => e.colour ? `` : glyph(e.glyph, e.tint); + const chips = card.entries.map((e, i) => ``).join(""); + return `

${esc(card.name)} ${have} / ${need}

+
+
${chips}
+ ${pick ? `

${pick.done ? "โœ“" : "๐Ÿ”’"} ${esc(pick.name)}${pick.tip ? ` ยท ${esc(pick.tip)}` : ""}

` : ""}
`; +} + // --- events --------------------------------------------------------------- document.addEventListener("click", (e) => { @@ -2626,3 +2662,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) refr }, 500); requestAnimationFrame(loop); })(); + if (d.ach) { + achPick = achPick === d.ach ? null : d.ach; + return render(); + } diff --git a/static/styles.css b/static/styles.css index 6dd93e0..6f73a16 100644 --- a/static/styles.css +++ b/static/styles.css @@ -799,3 +799,17 @@ button.swatch { width: 36px; height: 36px; } .swatch.on { border-color: var(--text); } .look-preview { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } .favour-option .bar { margin-top: 4px; } + +/* --- achievements ----------------------------------------------------- */ +.ach-count { color: var(--gold); font-variant-numeric: tabular-nums; margin-left: 6px; } +.ach-card.full { border-color: var(--gold); } +.ach-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } +.ach { width: 40px; height: 40px; display: grid; place-items: center; padding: 0; border-radius: 8px; + background: var(--panel-2); border: 2px solid transparent; cursor: pointer; } +.ach .gi { width: 26px; height: 26px; } +.ach .swatch { width: 22px; height: 22px; border-radius: 4px; } +.ach:not(.done) > * { opacity: .25; filter: grayscale(1); } +.ach.done { border-color: color-mix(in srgb, var(--gold) 45%, transparent); } +.ach.held { border: 2px dashed var(--gold); } +.ach.on { border-color: var(--text); } +.ach-line { margin: 10px 0 0; } diff --git a/tests/test_game.py b/tests/test_game.py index 71aab81..401ba12 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1077,6 +1077,30 @@ class LootTest(unittest.TestCase): game.migrate(world, T0) self.assertEqual(p["patterns"], []) + def test_achievements_read_what_you_have(self): + world, p = self.smith() + card = lambda name: next(c for s in game.achievements(p) for c in s["cards"] if c["name"] == name) + done = lambda name: [e["name"] for e in card(name)["entries"] if e["done"]] + self.assertEqual(len(card("Watchtower Depths")["entries"]), 7) + self.assertEqual(done("Watchtower Depths"), []) + p["patterns"].append("pattern_ogre_forged_warhammer") + self.assertEqual(done("Watchtower Depths"), ["Ogre-forged warhammer"]) + # A unique stays found after it's gone. + game.bank_add(world, p, "ogres_knuckle", 1, T0, game.empty_summary()) + game.bank_take(p, "ogres_knuckle", 1) + self.assertEqual(done("Uniques"), ["Ogre's knuckle"]) + self.assertEqual(done("Masters"), []) + p["xp"]["mining"] = game.XP_TABLE[50] + self.assertEqual(done("Masters"), ["Master miner"]) + + def test_old_worlds_find_the_uniques_they_hold(self): + world, p = self.smith() + del p["found"] + p["equip"]["ring"] = "ogres_knuckle" + world["version"] = 30 + game.migrate(world, T0) + self.assertEqual(p["found"], ["ogres_knuckle"]) + class NewSkillsTest(unittest.TestCase): def test_tools_speed_up_gathering(self):