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