Difficulty Unlock Loop
The key words MUST, MUST NOT, and MAY are used as in RFC 2119.
PSZ's replay structure: clearing the story raises the ceiling. Three tiers — Normal, Hard, Super-Hard — gate behind story completion, per character. The per-difficulty enemy scaling, rewards (#318 tiers), and technique levels already exist; this is the loop that gates access to them.
The rule
- Normal is always unlocked — even on a brand-new character, even before the unlock state is initialized.
- Reporting the story finale (
dark_castle) on Normal MUST unlock Hard for that character. - Reporting
dark_castleon Hard MUST unlock Super-Hard. - The unlock fires at guild turn-in (alongside
complete_mission), keyed on the difficulty the run was played at — the samedifficultyfield the reward grant reads.
Only the finale gates tiers; clearing side or variant quests on a difficulty does nothing. The progression map (GameState.DIFFICULTY_PROGRESSION) is normal → hard → super-hard; the finale id is GameState.STORY_FINALE_QUEST.
Persistence
Unlock state is per character, mirroring completed_missions exactly: GameState.unlocked_difficulties is the runtime list, CharacterManager swaps it in/out per slot, SaveManager persists it in the character dict (save v7). Old saves migrate by seeding ["normal"]; a character whose completed_missions already contains the finale retroactively keeps Hard (the cleared difficulty is unrecoverable from pre-v7 saves, so the grant is conservative — first tier up only).
Guild counter gating (removed for now — see status note)
When the selector returns (at character select), tiers locked for the
character MUST be presented as locked rather than silently
hidden, with the unlock condition surfaced ("Clear the story on Normal"), and a
locked tier MUST NOT be selectable. Current state: the
guild counter has no difficulty submenu at all — accepting a quest goes straight
to the confirm on Normal. The historical per-quest submenu (all three tiers, the
[LOCKED] tag, input-gate + async-confirm re-checks) is removed; this
gating contract moves to the character-select selector when it lands.
Implemented by
scripts/autoloads/game_state.gd—unlocked_difficulties,is_difficulty_unlocked,unlock_difficulty,apply_quest_clear_unlock, and the progression constants.scripts/autoloads/character_manager.gd— per-slot swap + the v7 seed migration.scripts/2d/guild_counter.gd— accepts on the impliedDEFAULT_DIFFICULTY("normal"); the difficulty submenu + turn-in unlock message were removed (status note).SessionManager.report_queststill appliesapply_quest_clear_unlock, so the dormant unlock state keeps tracking.
Testing
Unit (test_difficulty_unlock): the finale-clear rule per tier, the non-finale no-op, and the always-on Normal — all exercising GameState/report_quest directly, so they stay valid with the picker removed. Post-build: the regression matrix's dark_castle phase asserts Hard is unlocked in the post-finale save snapshot.