Achievements page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f6951b0f12
commit
a05530b802
5 changed files with 152 additions and 4 deletions
1
app.py
1
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)
|
||||
|
|
|
|||
73
game.py
73
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."),
|
||||
|
|
|
|||
|
|
@ -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() ? `<span class="badge">new</span>` : "", notesUnread() ? `What's new: ${C.patch_notes[0].title}` : "")}
|
||||
<div class="navhead">You</div>
|
||||
${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 `<div class="panel"><h2>Achievements <span class="ach-count">${have} / ${need}</span></h2>
|
||||
<div class="bar gold thick"><span style="width:${have / need * 100}%"></span></div></div>`
|
||||
+ sections.map((s, si) => {
|
||||
const [sh, sn] = tally(s.cards.flatMap((c) => c.entries));
|
||||
return `<h3 class="section-title">${esc(s.name)} <span class="ach-count">${sh} / ${sn}</span></h3>
|
||||
<div class="grid">${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}</div>`;
|
||||
}).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 ? `<span class="swatch" style="background:${e.colour}"></span>` : glyph(e.glyph, e.tint);
|
||||
const chips = card.entries.map((e, i) => `<button class="ach ${e.done ? "done" : ""} ${e.held ? "held" : ""} ${pick === e ? "on" : ""}" data-ach="${key}.${i}"
|
||||
title="${esc(`${e.name}${e.tip ? ` · ${e.tip}` : ""}`)}" aria-label="${esc(e.name)}">${icon(e)}</button>`).join("");
|
||||
return `<div class="panel ach-card ${have === need ? "full" : ""}"><h2>${esc(card.name)} <span class="ach-count">${have} / ${need}</span></h2>
|
||||
<div class="bar gold"><span style="width:${have / need * 100}%"></span></div>
|
||||
<div class="ach-chips">${chips}</div>
|
||||
${pick ? `<p class="small ach-line">${pick.done ? "✓" : "🔒"} <b>${esc(pick.name)}</b>${pick.tip ? ` <span class="muted">· ${esc(pick.tip)}</span>` : ""}</p>` : ""}</div>`;
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue