diff --git a/Dockerfile b/Dockerfile index 8858dd6..de0ccd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ WORKDIR /app # front-end (examples/web_app.py) could not give them. COPY requirements.txt ./ RUN apt-get update && apt-get install -y --no-install-recommends git \ - && pip install --no-cache-dir "tak @ git+https://github.com/Stephenson-Software/tak@v0.2.0" \ + && pip install --no-cache-dir "tak @ git+https://github.com/Stephenson-Software/tak@963cd3c94c07a98eaeff31f6a9796b90bd023e64" \ && apt-get purge -y git && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY src/ ./src/ diff --git a/requirements.txt b/requirements.txt index e8cd316..d2c669e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,5 @@ # tak is the text-adventure kit extracted from this game # (https://github.com/Stephenson-Software/tak), which FishE now runs on. # Installing it needs git on the PATH. -tak @ git+https://github.com/Stephenson-Software/tak@eedf89fe142221c430787837d43d29d3ccfa7f07 +tak @ git+https://github.com/Stephenson-Software/tak@963cd3c94c07a98eaeff31f6a9796b90bd023e64 jsonschema>=4.0,<5.0 diff --git a/src/achievements/achievements.py b/src/achievements/achievements.py index 282c23f..468fc0b 100644 --- a/src/achievements/achievements.py +++ b/src/achievements/achievements.py @@ -13,9 +13,22 @@ GOAL_AMOUNT = 10000 GOAL_MILESTONE_NAME = "Reached Goal" +# Achievements on arcade (https://danielstephenson.dev/play, Stephenson-Software +# RFC 0014): a curated subset of the milestones below carries an "arcadeId", +# plus the goal and the retirement that ends a run. The same ids are declared +# for the game "fishe" in the gateway's config/play/boards.yaml, and are +# permanent: never rename one. "arcadeHidden" marks one the arcade page lists +# as "Hidden achievement" until it is earned. See achievements/arcade.py. +GOAL_ARCADE_ID = "reached-goal" +GOAL_DESCRIPTION = "Reach $10,000" +RETIRED_ARCADE_ID = "retired" +RETIRED_TITLE = "Retired" +RETIRED_DESCRIPTION = "Retire with your fortune made" + MILESTONES = [ { "name": "First Catch", + "arcadeId": "first-catch", "stat": "totalFishCaught", "threshold": 1, "description": "Catch your first fish", @@ -28,6 +41,7 @@ }, { "name": "Master Fisher", + "arcadeId": "master-fisher", "stat": "totalFishCaught", "threshold": 1000, "description": "Catch 1,000 fish", @@ -40,6 +54,7 @@ }, { "name": "Big Earner", + "arcadeId": "big-earner", "stat": "totalMoneyMade", "threshold": 1000, "description": "Earn $1,000 total", @@ -52,6 +67,7 @@ }, { "name": "First Mate", + "arcadeId": "first-mate", "stat": "totalWorkersHired", "threshold": 1, "description": "Hire your first crew member", @@ -70,6 +86,7 @@ }, { "name": "Took the Helm", + "arcadeId": "took-the-helm", "stat": "totalVoyagesCaptained", "threshold": 1, "description": "Captain a voyage yourself", @@ -82,6 +99,8 @@ }, { "name": "Letter of Marque", + "arcadeId": "letter-of-marque", + "arcadeHidden": True, "stat": "totalRaids", "threshold": 1, "description": "Put a boat to piracy for a day", @@ -100,6 +119,7 @@ }, { "name": "First Export", + "arcadeId": "first-export", "stat": "totalFishExported", "threshold": 1, "description": "Ship your first fish to another village", @@ -118,12 +138,14 @@ }, { "name": "Homeowner", + "arcadeId": "homeowner", "stat": "highestHomeTier", "threshold": 2, "description": "Buy your first home", }, { "name": "Waterfront Manor", + "arcadeId": "waterfront-manor", "stat": "highestHomeTier", "threshold": 5, "description": "Own the finest home in the village", @@ -136,6 +158,7 @@ }, { "name": "Property Mogul", + "arcadeId": "property-mogul", "stat": "totalPropertiesBought", "threshold": 5, "description": "Buy 5 investment properties over your career", @@ -168,3 +191,48 @@ def getNewlyEarned(stats): stats.earnedMilestones.append(milestone["name"]) newly.append(milestone) return newly + + +def arcadeIdForName(name): + """The arcade achievement id an earned milestone name stands for, or None. + + Covers the goal too, which is recorded on stats.earnedMilestones under + GOAL_MILESTONE_NAME like any milestone.""" + if name == GOAL_MILESTONE_NAME: + return GOAL_ARCADE_ID + for milestone in MILESTONES: + if milestone["name"] == name: + return milestone.get("arcadeId") + return None + + +def arcadeDeclarations(): + """Every arcade achievement the game can unlock, as the gateway's + boards.yaml declares them: [{id, title, description, hidden}], in order.""" + declared = [ + { + "id": milestone["arcadeId"], + "title": milestone["name"], + "description": milestone["description"], + "hidden": bool(milestone.get("arcadeHidden", False)), + } + for milestone in MILESTONES + if "arcadeId" in milestone + ] + declared.append( + { + "id": GOAL_ARCADE_ID, + "title": GOAL_MILESTONE_NAME, + "description": GOAL_DESCRIPTION, + "hidden": False, + } + ) + declared.append( + { + "id": RETIRED_ARCADE_ID, + "title": RETIRED_TITLE, + "description": RETIRED_DESCRIPTION, + "hidden": False, + } + ) + return declared diff --git a/src/achievements/arcade.py b/src/achievements/arcade.py new file mode 100644 index 0000000..16da0e0 --- /dev/null +++ b/src/achievements/arcade.py @@ -0,0 +1,54 @@ +# @author Daniel McCoy Stephenson +"""FishE's achievements on arcade (Stephenson-Software RFC 0014), via tak.arcade. + +Each function here is called at the moment something is earned - a milestone +announced, the goal reached, the run retired - and on load, to re-assert what +an existing save has already earned. tak.arcade.unlock is fire-and-forget and +idempotent: it does nothing outside the browser on arcade or for a player who +is not signed in, and the service ignores a repeat. Nothing here reads or +writes save state beyond reading stats.earnedMilestones; a failure is +swallowed, never raised into the game. +""" + +from achievements.achievements import ( + RETIRED_ARCADE_ID, + arcadeIdForName, +) + + +def unlock(achievementId): + """Report one achievement. Never raises.""" + try: + from tak import arcade + + arcade.unlock(achievementId) + except Exception: + pass + + +def milestonesEarned(milestones): + """Report the arcade ids of milestones just earned (rows of MILESTONES).""" + for milestone in milestones: + achievementId = milestone.get("arcadeId") + if achievementId: + unlock(achievementId) + + +def nameEarned(name): + """Report the arcade id an earned milestone name (or the goal) stands for.""" + achievementId = arcadeIdForName(name) + if achievementId: + unlock(achievementId) + + +def retired(): + unlock(RETIRED_ARCADE_ID) + + +def catchUp(stats): + """Re-assert every achievement a loaded save has already earned. + + For a save that earned milestones before arcade achievements existed, or + while the player was signed out. Reads stats.earnedMilestones only.""" + for name in list(getattr(stats, "earnedMilestones", []) or []): + nameEarned(name) diff --git a/src/fishE.py b/src/fishE.py index 0da1c9a..9bfd7f8 100644 --- a/src/fishE.py +++ b/src/fishE.py @@ -18,6 +18,7 @@ from tak.saves import chooseSlot, deleteSlot from browserSaveSync import syncBrowserSaves from achievements import achievements +from achievements import arcade as arcadeAchievements from achievements.achievements import GOAL_AMOUNT, GOAL_MILESTONE_NAME from progression import progression from config.config import Config @@ -175,6 +176,10 @@ def __init__(self, interfaceType=INTERFACE_TYPE): # player (see progression.catchUp). progression.catchUp(self.player, self.stats) + # Re-assert on arcade the achievements this save has already earned + # (fire-and-forget; reads stats.earnedMilestones, writes nothing). + arcadeAchievements.catchUp(self.stats) + # The game opens on the docks, with fishing as the only thing on the # menu. Everywhere else in the village is revealed as it is earned - # see src/progression. @@ -259,6 +264,7 @@ def _runGameLoop(self): newlyEarned = achievements.getNewlyEarned(self.stats) for milestone in newlyEarned: self.prompt.text += " [Milestone unlocked: %s!]" % milestone["name"] + arcadeAchievements.milestonesEarned(newlyEarned) # announce the one thing the player has just opened up, with the # reason they opened it, so the newly-appeared menu entry is @@ -301,6 +307,7 @@ def announceGoalIfReached(self): " [GOAL REACHED! You've built your fortune of $%d! " "Keep fishing, or retire from the Home menu.]" % GOAL_AMOUNT ) + arcadeAchievements.nameEarned(GOAL_MILESTONE_NAME) return True return False diff --git a/src/location/home.py b/src/location/home.py index 2533a64..655c0fb 100644 --- a/src/location/home.py +++ b/src/location/home.py @@ -5,6 +5,7 @@ from stats.stats import Stats from ui.userInterface import UserInterface from achievements import achievements +from achievements import arcade as arcadeAchievements from housing import housing from progression import progression @@ -295,4 +296,6 @@ def retire(self): "Here's how your career adds up:", "", ] + self._statsLines() + # The run's ending, reported to arcade as it happens. + arcadeAchievements.retired() self.userInterface.showDialogue("\n".join(lines)) diff --git a/tests/achievements/test_arcade.py b/tests/achievements/test_arcade.py new file mode 100644 index 0000000..4814bd4 --- /dev/null +++ b/tests/achievements/test_arcade.py @@ -0,0 +1,208 @@ +"""FishE's arcade achievements (tak.arcade, Stephenson-Software RFC 0014). + +tak.arcade.unlock is replaced by a recorder, so these check which ids the game +reports and when - never the network. Every declared id has to be reachable +from a real code path, and reporting one must not change a byte of the save.""" + +import json +import os +import tempfile +from unittest.mock import MagicMock + +import pytest +import tak.arcade + +from src import fishE +from src.achievements import achievements +from src.location.enum.locationType import LocationType +from src.player.player import Player +from src.player.playerJsonReaderWriter import PlayerJsonReaderWriter +from src.stats.stats import Stats +from src.stats.statsJsonReaderWriter import StatsJsonReaderWriter +from src.prompt.prompt import Prompt +from src.world.timeService import TimeService + +from tests.test_fishE import ( + createGameForPersistence, + createGameForPlay, + createGameThroughInit, +) +from tests.location.test_home import createHome + + +@pytest.fixture +def unlocks(monkeypatch): + calls = [] + monkeypatch.setattr( + tak.arcade, "unlock", lambda achievementId: calls.append(achievementId) + ) + return calls + + +DECLARED = [a["id"] for a in achievements.arcadeDeclarations()] + + +def test_declarations_are_valid_unique_and_spoiler_safe(): + assert 6 <= len(DECLARED) <= 12 + assert len(set(DECLARED)) == len(DECLARED) + for declared in achievements.arcadeDeclarations(): + assert tak.arcade.ID_PATTERN.match(declared["id"]), declared + assert declared["title"] and declared["description"] + # the two ids arcade-social's example already uses for FishE + assert "first-catch" in DECLARED and "reached-goal" in DECLARED + assert [a["id"] for a in achievements.arcadeDeclarations() if a["hidden"]] == [ + "letter-of-marque" + ] + + +def playOneTurn(game): + game.locations[LocationType.HOME].run.return_value = LocationType.NONE + game.play() + + +@pytest.mark.parametrize( + "milestone", + [m for m in achievements.MILESTONES if "arcadeId" in m], + ids=lambda m: m["arcadeId"], +) +def test_each_milestone_is_unlocked_when_the_game_loop_announces_it(unlocks, milestone): + game = createGameForPlay() + setattr(game.stats, milestone["stat"], milestone["threshold"]) + + playOneTurn(game) + + assert "Milestone unlocked: %s!" % milestone["name"] in game.prompt.text + assert milestone["arcadeId"] in unlocks + + +def test_a_milestone_without_an_arcade_id_reports_nothing(unlocks): + game = createGameForPlay() + game.stats.totalFishCaught = 100 # First Catch (arcade) + Seasoned Angler (not) + + playOneTurn(game) + + assert "Milestone unlocked: Seasoned Angler!" in game.prompt.text + assert unlocks == ["first-catch"] + + +def test_a_milestone_is_reported_once_not_on_every_turn(unlocks): + game = createGameForPlay() + game.stats.totalFishCaught = 1 + game.locations[LocationType.HOME].run.return_value = LocationType.HOME + turns = iter([LocationType.HOME, LocationType.HOME, LocationType.NONE]) + game.locations[LocationType.HOME].run.side_effect = lambda: next(turns) + + game.play() + + assert unlocks == ["first-catch"] + + +def test_reaching_the_goal_is_unlocked_once(unlocks): + game = createGameForPlay() + game.player.money = fishE.GOAL_AMOUNT + + playOneTurn(game) + assert game.announceGoalIfReached() is False # already announced + + assert unlocks.count("reached-goal") == 1 + + +def test_retiring_is_unlocked(unlocks): + home = createHome() + home.userInterface.showDialogue = MagicMock() + + home.retire() + + assert unlocks == ["retired"] + + +def test_choosing_retire_from_the_home_menu_unlocks_the_ending(unlocks): + home = createHome() + home.stats.earnedMilestones.append(achievements.GOAL_MILESTONE_NAME) + home.userInterface.showOptions = MagicMock(return_value="5") + home.userInterface.showDialogue = MagicMock() + + assert home.run() == LocationType.NONE + assert unlocks == ["retired"] + + +def test_every_declared_id_is_reachable(unlocks): + for milestone in achievements.MILESTONES: + if "arcadeId" in milestone: + game = createGameForPlay() + setattr(game.stats, milestone["stat"], milestone["threshold"]) + playOneTurn(game) + game = createGameForPlay() + game.player.money = fishE.GOAL_AMOUNT + playOneTurn(game) + home = createHome() + home.userInterface.showDialogue = MagicMock() + home.retire() + + assert set(unlocks) == set(DECLARED) + + +def test_loading_a_save_re_asserts_what_it_already_earned(unlocks): + with tempfile.TemporaryDirectory() as data_directory: + stats = Stats() + stats.totalFishCaught = 5 + stats.earnedMilestones = ["First Catch", "Pocket Money", "Reached Goal"] + createGameThroughInit( + data_directory, + { + "player.json": PlayerJsonReaderWriter().createJsonFromPlayer(Player()), + "stats.json": StatsJsonReaderWriter().createJsonFromStats(stats), + }, + ) + + assert unlocks == ["first-catch", "reached-goal"] + + +def test_a_new_game_reports_nothing(unlocks): + with tempfile.TemporaryDirectory() as data_directory: + createGameThroughInit(data_directory, {}) + assert unlocks == [] + + +def test_an_unlock_that_raises_never_reaches_the_game(monkeypatch): + def boom(achievementId): + raise RuntimeError("bridge gone") + + monkeypatch.setattr(tak.arcade, "unlock", boom) + game = createGameForPlay() + game.stats.totalFishCaught = 1 + game.player.money = fishE.GOAL_AMOUNT + + playOneTurn(game) + + assert "First Catch" in game.stats.earnedMilestones + + +def savedBytes(data_directory, earn): + fishE.Player, fishE.Stats, fishE.TimeService = Player, Stats, TimeService + game = createGameForPersistence(data_directory) + game.player = Player() + game.stats = Stats() + game.timeService = TimeService(game.player, game.stats) + game.prompt = Prompt("What would you like to do?") + game.player.money = fishE.GOAL_AMOUNT + earn(game) + game.save() + slot = os.path.join(data_directory, "slot_1") + files = {} + for name in sorted(os.listdir(slot)): + with open(os.path.join(slot, name), "rb") as saved: + files[name] = saved.read() + return files + + +def test_unlocking_does_not_change_a_byte_of_the_save(monkeypatch): + recorded = [] + with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b: + monkeypatch.setattr(tak.arcade, "unlock", lambda i: None) + quiet = savedBytes(a, lambda game: game.announceGoalIfReached()) + monkeypatch.setattr(tak.arcade, "unlock", recorded.append) + loud = savedBytes(b, lambda game: game.announceGoalIfReached()) + assert recorded == ["reached-goal"] + assert quiet == loud + assert json.loads(quiet["stats.json"])["earnedMilestones"]