From 0cc9719e2a96e3e3b95113a14771b361f3cd99b2 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 14:04:44 -0600 Subject: [PATCH] Achievements on arcade via tak.arcade (RFC 0014) Nine achievements, reported with tak.arcade.unlock at the moment they happen: four discoveries (the count, the manifest, the old depot, the answer to the count), everything the station can tell you, the first remembered choice, and one per ending (Aksel staying is hidden). Game.learn reports a newly learned fact, Scene.remember the remembered choice, and a loaded save reports what it already earned. Nothing is written to the save. tak pinned at 963cd3c, which adds tak.arcade. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116e9DMUhtNXM5bHUdMhDAn --- requirements.txt | 2 +- src/overwinter/achievements.py | 144 ++++++++++++++++++++++++ src/overwinter/game.py | 17 ++- src/overwinter/scenes/base.py | 3 +- tests/test_achievements.py | 200 +++++++++++++++++++++++++++++++++ 5 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 src/overwinter/achievements.py create mode 100644 tests/test_achievements.py diff --git a/requirements.txt b/requirements.txt index f310d0f..c3a742d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -tak @ git+https://github.com/Stephenson-Software/tak@eedf89fe142221c430787837d43d29d3ccfa7f07 +tak @ git+https://github.com/Stephenson-Software/tak@963cd3c94c07a98eaeff31f6a9796b90bd023e64 jsonschema diff --git a/src/overwinter/achievements.py b/src/overwinter/achievements.py new file mode 100644 index 0000000..fba127a --- /dev/null +++ b/src/overwinter/achievements.py @@ -0,0 +1,144 @@ +# @author Daniel McCoy Stephenson +"""Achievements on arcade, reported through tak.arcade (Stephenson-Software +RFC 0014). + +Knowledge is the progression in Overwinter, so the achievements are facts: +the first count, the manifest, the old depot, the answer to the count, +everything the station can tell you, and each of the three ways the winter +ends. One more is for the first choice someone will hold you to. + +Every id below is declared for the game in the gateway's +config/play/boards.yaml; an id is permanent once it has been used, so a row +here may be retitled but never renamed. Reporting is fire-and-forget: +tak.arcade does nothing off arcade, signed out, or in tests, and never +raises. Nothing here reads or writes the save beyond looking at what the +state already knows - an achievement is never stored in the save file. +""" + +from tak import arcade + +from overwinter import facts, flags, people + +ACHIEVEMENTS = [ + { + "id": "counted", + "title": "Counted", + "description": "Count the station's store for yourself", + "hidden": False, + }, + { + "id": "signed-for", + "title": "Signed For", + "description": "Find out what was landed in September", + "hidden": False, + }, + { + "id": "old-stores", + "title": "Old Stores", + "description": "Find what an earlier winter left behind", + "hidden": False, + }, + { + "id": "the-answer", + "title": "The Answer to the Count", + "description": "Find out where the missing food went", + "hidden": False, + }, + { + "id": "the-whole-winter", + "title": "The Whole Winter", + "description": "Learn everything the station can tell you", + "hidden": False, + }, + { + "id": "remembered", + "title": "They Will Remember That", + "description": "Make a choice someone will hold you to", + "hidden": False, + }, + { + "id": "twentieth-morning", + "title": "The Twentieth Morning", + "description": "See the winter through to the plane", + "hidden": False, + }, + { + "id": "in-the-dark", + "title": "In the Dark", + "description": "End the winter before the plane was due", + "hidden": False, + }, + { + "id": "his-choice", + "title": "His Choice", + "description": "Let Aksel stay on the island", + "hidden": True, + }, +] + +IDS = [achievement["id"] for achievement in ACHIEVEMENTS] + +# Facts that are achievements the moment they are learned. The endings are +# facts too: each is learned at the moment its winter ends. +FACT_ACHIEVEMENTS = { + facts.THE_STORE: "counted", + facts.THE_MANIFEST: "signed-for", + facts.THE_DEPOT: "old-stores", + facts.THE_FIFTH: "the-answer", + facts.FIVE_OUT: "twentieth-morning", + facts.THE_DARK_FLIGHT: "in-the-dark", + facts.AKSEL_STAYED: "his-choice", +} + +# Everything the station can tell you: every fact but the endings. +CLUE_FACTS = [fact for fact in facts.FACTS if fact not in facts.ENDINGS] +WHOLE_WINTER = "the-whole-winter" +REMEMBERED = "remembered" + + +def unlock(achievementId): + """Report one unlock. Idempotent on the service; never raises.""" + try: + arcade.unlock(achievementId) + except Exception: + pass + + +def factLearned(state, factId): + """A fact was just learned for the first time.""" + achievementId = FACT_ACHIEVEMENTS.get(factId) + if achievementId is not None: + unlock(achievementId) + if factId in CLUE_FACTS and _knowsEveryClue(state): + unlock(WHOLE_WINTER) + + +def choiceRemembered(): + """Someone will hold the player to a choice.""" + unlock(REMEMBERED) + + +def catchUp(state): + """Report what a loaded save has already earned, so a winter played + before achievements existed still counts. Only reads the state.""" + for factId in state.facts: + achievementId = FACT_ACHIEVEMENTS.get(factId) + if achievementId is not None: + unlock(achievementId) + if _knowsEveryClue(state): + unlock(WHOLE_WINTER) + if _rememberedSomething(state): + unlock(REMEMBERED) + + +def _rememberedSomething(state): + # Flags are never forgotten in Overwinter, so a remembered choice is + # still on the state: any of the ones people hold you to, or the last + # one on the strip. + if flags.BACKED_AKSEL_STAYING in state.flags: + return True + return any(flag in state.flags for flag in people.REMEMBERED) + + +def _knowsEveryClue(state): + return all(state.knows(fact) for fact in CLUE_FACTS) diff --git a/src/overwinter/game.py b/src/overwinter/game.py index f8afa5c..a10ae2d 100644 --- a/src/overwinter/game.py +++ b/src/overwinter/game.py @@ -18,7 +18,14 @@ ) from tak.ui import UIType, createUserInterface -from overwinter import endings, premise, progression, scenes, usageReporting +from overwinter import ( + achievements, + endings, + premise, + progression, + scenes, + usageReporting, +) from overwinter.config import Config from overwinter.header import buildHeader from overwinter.state import SAVE_FILENAME, SCHEMA_PATH, State @@ -106,6 +113,9 @@ def __init__(self, interfaceType=INTERFACE_TYPE): self.prompt.text = "Day %d. What would you like to do?" % self.state.day # A loaded save may predate an unlock, or have earned one since. progression.catchUp(self.state) + # Arcade achievements the save has already earned (reported, never + # written to the save). + achievements.catchUp(self.state) self.scenes = scenes.build(self) # A brand-new game opens on where you are and who is here, once. self.showOpening = kind == "new" or ( @@ -115,7 +125,10 @@ def __init__(self, interfaceType=INTERFACE_TYPE): # --- the scenes' hooks ------------------------------------------------ def learn(self, factId): """Promote something to knowledge. Returns True if it was new.""" - return self.state.learn(factId) + if not self.state.learn(factId): + return False + achievements.factLearned(self.state, factId) + return True # --- play ------------------------------------------------------------- def play(self): diff --git a/src/overwinter/scenes/base.py b/src/overwinter/scenes/base.py index ff42910..b30f8e0 100644 --- a/src/overwinter/scenes/base.py +++ b/src/overwinter/scenes/base.py @@ -1,5 +1,5 @@ # @author Daniel McCoy Stephenson -from overwinter import endings, people, winter +from overwinter import achievements, endings, people, winter from overwinter.flags import PLANE_ON_STRIP STORM_REASON = "the storm - Marit's order is nobody past the door" @@ -57,6 +57,7 @@ def go(self, destination): def remember(self, who): """The beat after a choice someone will hold you to - for good.""" + achievements.choiceRemembered() self.ui.showDialogue("[%s will remember that.]" % who) def talk(self, npc): diff --git a/tests/test_achievements.py b/tests/test_achievements.py new file mode 100644 index 0000000..eb651b8 --- /dev/null +++ b/tests/test_achievements.py @@ -0,0 +1,200 @@ +"""Arcade achievements: every declared one is reachable, each route reports +what it should at the moment it happens, and none of it touches the save.""" + +import json +import re + +import pytest + +from overwinter import achievements, facts, flags +from overwinter.state import DARK_FLIGHT_DAY, State +from test_endings import TELL_DOV +from test_game import ( + DAY_FIVE, + DAY_ONE, + DAY_SEVEN, + DAY_SIX, + DAY_TEN, + DAY_TWO, + DAYS_THREE_AND_FOUR, + FULL_RUN, + ON_THE_STRIP, + SLEEP, + STORM, + TO_THE_PLANE, + TURN_IN, +) + + +@pytest.fixture +def unlocked(monkeypatch): + """Record every tak.arcade.unlock call instead of making it.""" + calls = [] + monkeypatch.setattr(achievements.arcade, "unlock", calls.append) + return calls + + +# FULL_RUN, plus an evening at the radio on day eleven so Dov's game is +# learned too: everything the station can tell you, then five on the plane. +EVERYTHING_RUN = ( + DAY_ONE + + DAY_TWO + + DAYS_THREE_AND_FOUR + + DAY_FIVE + + DAY_SIX + + DAY_SEVEN + + STORM + + DAY_TEN + + [ + "Go to the galley", + "Count the store", # -> afternoon + "Cook the meal", # -> evening + "Go to the radio room", + "Sit in on the evening schedule", # DOVS_GAME; day ends -> day 12 + ] + + TURN_IN + + TO_THE_PLANE[2:] + + ON_THE_STRIP +) + +DARK_FLIGHT_RUN = ( + DAY_ONE + + DAY_TWO + + DAYS_THREE_AND_FOUR + + TELL_DOV + + ["Send it.", "[Back]"] + + TURN_IN + + SLEEP * (DARK_FLIGHT_DAY - 6) + + ["Quit"] +) + + +def _stayRun(): + script = list(FULL_RUN) + script[script.index("He's coming with us")] = "Let him go" + return script + + +def test_the_declarations_are_valid_and_unique(): + pattern = re.compile(r"^[a-z][a-z0-9-]{1,30}$") + assert 6 <= len(achievements.ACHIEVEMENTS) <= 12 + assert len(set(achievements.IDS)) == len(achievements.IDS) + for achievement in achievements.ACHIEVEMENTS: + assert pattern.match(achievement["id"]), achievement + assert achievement["title"] and achievement["description"] + assert isinstance(achievement["hidden"], bool) + # The one spoiler is the one hidden. + assert [a["id"] for a in achievements.ACHIEVEMENTS if a["hidden"]] == ["his-choice"] + for achievementId in achievements.FACT_ACHIEVEMENTS.values(): + assert achievementId in achievements.IDS + # One per ending. + for ending in facts.ENDINGS: + assert ending in achievements.FACT_ACHIEVEMENTS + + +def test_five_on_the_plane_with_everything_known(scripted, unlocked): + game, ui = scripted(list(EVERYTHING_RUN)) + game.play() + assert game.state.ending == facts.FIVE_OUT + assert all(game.state.knows(f) for f in achievements.CLUE_FACTS) + assert set(unlocked) == { + "counted", + "signed-for", + "old-stores", + "the-answer", + "the-whole-winter", + "remembered", + "twentieth-morning", + } + # Each fact's achievement is reported once, when it is learned. + assert unlocked.count("counted") == 1 + assert unlocked.count("the-whole-winter") == 1 + assert unlocked[-1] == "twentieth-morning" + + +def test_the_full_run_without_doves_game_is_not_the_whole_winter(scripted, unlocked): + game, ui = scripted(list(FULL_RUN)) + game.play() + assert "twentieth-morning" in unlocked + assert "the-whole-winter" not in unlocked + + +def test_letting_aksel_stay(scripted, unlocked): + game, ui = scripted(_stayRun()) + game.play() + assert game.state.ending == facts.AKSEL_STAYED + assert "his-choice" in unlocked + assert "twentieth-morning" not in unlocked + assert unlocked[-1] == "his-choice" + + +def test_the_dark_flight(scripted, unlocked): + game, ui = scripted(list(DARK_FLIGHT_RUN)) + game.play() + assert game.state.ending == facts.THE_DARK_FLIGHT + assert "in-the-dark" in unlocked + assert "the-answer" in unlocked + assert "remembered" in unlocked + assert "twentieth-morning" not in unlocked + + +def test_every_declared_achievement_is_reached_by_some_route(scripted, unlocked): + for script in (EVERYTHING_RUN, _stayRun(), DARK_FLIGHT_RUN): + game, ui = scripted(list(script)) + game.play() + assert set(unlocked) == set(achievements.IDS) + + +def test_a_new_game_reports_nothing_before_anything_happens(scripted, unlocked): + game, ui = scripted(["Create New Save", "Quit"]) + game.play() + assert unlocked == [] + + +def test_a_loaded_save_reports_what_it_already_earned_and_is_not_changed( + scripted, unlocked +): + game, ui = scripted(list(DARK_FLIGHT_RUN)) + game.play() + path = game.saveFileManager.get_save_path("save.json") + with open(path, "rb") as saveFile: + before = saveFile.read() + del unlocked[:] + + game2, ui2 = scripted(["Load Slot 1", "Quit"]) + game2.play() + # Loading reports the save's achievements without playing anything. + assert set(unlocked) == { + "counted", + "signed-for", + "old-stores", + "the-answer", + "remembered", + "in-the-dark", + } + # The quit itself saves; the save holds no trace of any achievement. + with open(path, "rb") as saveFile: + after = json.loads(saveFile.read()) + assert after == json.loads(before) + assert "achievements" not in after + + +def test_catch_up_only_reads_the_state(unlocked): + state = State() + for fact in achievements.CLUE_FACTS + [facts.AKSEL_STAYED]: + state.learn(fact) + state.flags[flags.BACKED_AKSEL_STAYING] = True + before = json.dumps(state.toDict(), sort_keys=True) + achievements.catchUp(state) + assert json.dumps(state.toDict(), sort_keys=True) == before + assert "the-whole-winter" in unlocked + assert "his-choice" in unlocked + assert "remembered" in unlocked + + +def test_an_unlock_that_raises_never_reaches_the_game(monkeypatch): + def boom(achievementId): + raise RuntimeError("no arcade") + + monkeypatch.setattr(achievements.arcade, "unlock", boom) + achievements.unlock("counted") # does not raise