From 8d8cced51a3d0f582ba553545a0a0ed5ebdae7e1 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 14:05:53 -0600 Subject: [PATCH] Achievements on arcade via tak.arcade (RFC 0014) Twelve arcade achievements for FishE: ten curated milestones carry an arcadeId (Letter of Marque is declared hidden), plus reaching the goal and retiring. Each is unlocked through tak.arcade.unlock at the moment the game announces it, and a loaded save re-asserts the ones it has already earned. Unlocks are fire-and-forget and touch no save state. Pins tak at 963cd3c (tak.arcade) in requirements.txt and the Dockerfile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116e9DMUhtNXM5bHUdMhDAn --- Dockerfile | 2 +- requirements.txt | 2 +- src/achievements/achievements.py | 68 ++++++++++ src/achievements/arcade.py | 54 ++++++++ src/fishE.py | 7 + src/location/home.py | 3 + tests/achievements/test_arcade.py | 208 ++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 src/achievements/arcade.py create mode 100644 tests/achievements/test_arcade.py 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"]