Skip to content

Commit 535614e

Browse files
author
Sorra
committed
Merge wl-CG-0MMLTPXVR0F9K0JS-meta-progression: Implement meta-progression system
2 parents 8c814a9 + 68758ae commit 535614e

6 files changed

Lines changed: 1651 additions & 15 deletions

File tree

example-games/main-street/MainStreetCards.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -856,11 +856,23 @@ const UPGRADE_TEMPLATES: UpgradeCard[] = [
856856
/**
857857
* Creates the full Business deck for a game (each template repeated
858858
* `copies` times to ensure adequate supply for 20 turns).
859+
*
860+
* @param copies Number of copies per template (default 3).
861+
* @param unlockedCardIds Optional list of unlocked card IDs for tier filtering.
862+
* When provided, only templates whose ID is in this list
863+
* are included. When omitted, the full pool is used.
859864
*/
860-
export function createBusinessDeck(copies: number = 3): BusinessCard[] {
865+
export function createBusinessDeck(
866+
copies: number = 3,
867+
unlockedCardIds?: string[],
868+
): BusinessCard[] {
869+
const templates = unlockedCardIds
870+
? BUSINESS_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id))
871+
: BUSINESS_TEMPLATES;
872+
861873
const deck: BusinessCard[] = [];
862874
for (let c = 0; c < copies; c++) {
863-
for (const template of BUSINESS_TEMPLATES) {
875+
for (const template of templates) {
864876
deck.push(makeBusiness({ ...template, id: `${template.id}-${c}` }));
865877
}
866878
}
@@ -869,11 +881,23 @@ export function createBusinessDeck(copies: number = 3): BusinessCard[] {
869881

870882
/**
871883
* Creates the full Event deck for a game.
884+
*
885+
* @param copies Number of copies per template (default 3).
886+
* @param unlockedCardIds Optional list of unlocked card IDs for tier filtering.
887+
* When provided, only templates whose ID is in this list
888+
* are included. When omitted, the full pool is used.
872889
*/
873-
export function createEventDeck(copies: number = 3): EventCard[] {
890+
export function createEventDeck(
891+
copies: number = 3,
892+
unlockedCardIds?: string[],
893+
): EventCard[] {
894+
const templates = unlockedCardIds
895+
? EVENT_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id))
896+
: EVENT_TEMPLATES;
897+
874898
const deck: EventCard[] = [];
875899
for (let c = 0; c < copies; c++) {
876-
for (const template of EVENT_TEMPLATES) {
900+
for (const template of templates) {
877901
deck.push({ ...template, id: `${template.id}-${c}` });
878902
}
879903
}
@@ -882,11 +906,23 @@ export function createEventDeck(copies: number = 3): EventCard[] {
882906

883907
/**
884908
* Creates the full Upgrade deck for a game.
909+
*
910+
* @param copies Number of copies per template (default 2).
911+
* @param unlockedCardIds Optional list of unlocked card IDs for tier filtering.
912+
* When provided, only templates whose ID is in this list
913+
* are included. When omitted, the full pool is used.
885914
*/
886-
export function createUpgradeDeck(copies: number = 2): UpgradeCard[] {
915+
export function createUpgradeDeck(
916+
copies: number = 2,
917+
unlockedCardIds?: string[],
918+
): UpgradeCard[] {
919+
const templates = unlockedCardIds
920+
? UPGRADE_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id))
921+
: UPGRADE_TEMPLATES;
922+
887923
const deck: UpgradeCard[] = [];
888924
for (let c = 0; c < copies; c++) {
889-
for (const template of UPGRADE_TEMPLATES) {
925+
for (const template of templates) {
890926
deck.push({ ...template, id: `${template.id}-${c}` });
891927
}
892928
}

example-games/main-street/MainStreetSaveLoad.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ import {
99
serializeMainStreetState,
1010
deserializeMainStreetState,
1111
} from './MainStreetState';
12+
import {
13+
TIER_DEFINITIONS,
14+
ORDERED_TIER_DEFINITIONS,
15+
deriveUnlockedCardIds,
16+
} from './MainStreetTiers';
1217

1318
export const MAIN_STREET_SAVE_SCHEMA_VERSION = 1;
14-
export const MAIN_STREET_CAMPAIGN_SCHEMA_VERSION = 1;
19+
export const MAIN_STREET_CAMPAIGN_SCHEMA_VERSION = 2;
1520
export const MAIN_STREET_GAME_TYPE = 'main-street';
1621
export const MAIN_STREET_RUN_SLOT = 'turn-start';
1722
export const MAIN_STREET_CAMPAIGN_SLOT = 'campaign-default';
@@ -31,12 +36,26 @@ export const mainStreetCampaignSerializer: SaveSerializer<
3136
> = {
3237
schemaVersion: MAIN_STREET_CAMPAIGN_SCHEMA_VERSION,
3338
serialize: (state) => structuredClone(state),
34-
deserialize: (data) => structuredClone(data),
39+
deserialize: (data) => {
40+
// v1 -> v2 migration: add schemaVersion, unlockedCardIds, milestoneHistory
41+
if (!data.schemaVersion || data.schemaVersion === 1) {
42+
return {
43+
...data,
44+
schemaVersion: 2,
45+
unlockedCardIds: deriveUnlockedCardIds(data.unlockedTiers),
46+
milestoneHistory: [],
47+
};
48+
}
49+
return structuredClone(data);
50+
},
3551
};
3652

3753
export function createDefaultCampaignProgress(): MainStreetCampaignProgress {
3854
return {
55+
schemaVersion: MAIN_STREET_CAMPAIGN_SCHEMA_VERSION,
3956
unlockedTiers: ['tier-1'],
57+
unlockedCardIds: TIER_DEFINITIONS['tier-1'].cumulativeCardIds.slice(),
58+
milestoneHistory: [],
4059
persistentReputation: 0,
4160
highestScore: 0,
4261
totalRuns: 0,
@@ -45,6 +64,74 @@ export function createDefaultCampaignProgress(): MainStreetCampaignProgress {
4564
};
4665
}
4766

67+
/**
68+
* Evaluates tier unlocks and updates campaign progress after a completed run.
69+
*
70+
* Called after EndCheck phase determines the game result.
71+
* Mutates the campaign progress in place, then persists it.
72+
*
73+
* @param campaign Current campaign progress (loaded from storage).
74+
* @param state Completed game state (after EndCheck).
75+
* @param store Save/Load store for persistence (optional; if provided, saves automatically).
76+
* @returns The updated campaign progress.
77+
*/
78+
export async function updateCampaignAfterRun(
79+
campaign: MainStreetCampaignProgress,
80+
state: MainStreetState,
81+
store?: SaveLoadStore,
82+
): Promise<MainStreetCampaignProgress> {
83+
const now = new Date().toISOString();
84+
85+
// Update statistics
86+
campaign.totalRuns += 1;
87+
if (state.gameResult === 'win') campaign.totalWins += 1;
88+
if (state.finalScore > campaign.highestScore) {
89+
campaign.highestScore = state.finalScore;
90+
}
91+
if (state.resourceBank.reputation > campaign.persistentReputation) {
92+
campaign.persistentReputation = state.resourceBank.reputation;
93+
}
94+
95+
// Evaluate tier unlocks (ordered by tier number)
96+
for (const tierDef of ORDERED_TIER_DEFINITIONS) {
97+
// Skip tier-1 (always unlocked) and already-unlocked tiers
98+
if (tierDef.id === 'tier-1') continue;
99+
if (campaign.unlockedTiers.includes(tierDef.id)) continue;
100+
101+
const reputationMet =
102+
state.resourceBank.reputation >= tierDef.reputationThreshold;
103+
const challengeMet = tierDef.challengeCondition(state);
104+
105+
if (reputationMet || challengeMet) {
106+
campaign.unlockedTiers.push(tierDef.id);
107+
campaign.milestoneHistory.push({
108+
tierId: tierDef.id,
109+
triggerType: reputationMet ? 'reputation' : 'challenge',
110+
reputationAtUnlock: reputationMet
111+
? state.resourceBank.reputation
112+
: null,
113+
challengeIdsAtUnlock: challengeMet
114+
? [...state.challengesCompleted]
115+
: null,
116+
runFinalScore: state.finalScore,
117+
runSeed: state.seed,
118+
unlockedAt: now,
119+
});
120+
}
121+
}
122+
123+
// Derive updated card list from all unlocked tiers
124+
campaign.unlockedCardIds = deriveUnlockedCardIds(campaign.unlockedTiers);
125+
campaign.lastUpdatedAt = now;
126+
127+
// Persist if store is provided
128+
if (store) {
129+
await saveCampaignProgress(store, campaign);
130+
}
131+
132+
return campaign;
133+
}
134+
48135
export async function saveTurnStartCheckpoint(
49136
store: SaveLoadStore,
50137
state: MainStreetState,

example-games/main-street/MainStreetState.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,48 @@ export interface MainStreetSerializedState {
214214
activityLog: LogEntry[];
215215
}
216216

217+
/** Record of a single milestone (tier unlock) achievement. */
218+
export interface MilestoneRecord {
219+
/** Tier ID that was unlocked, e.g. 'tier-3'. */
220+
tierId: string;
221+
/** Which trigger path caused the unlock. */
222+
triggerType: 'reputation' | 'challenge';
223+
/** For reputation triggers: the reputation value at end-of-run. For challenge triggers: null. */
224+
reputationAtUnlock: number | null;
225+
/** For challenge triggers: the IDs of challenges completed that satisfied the condition. For reputation triggers: null. */
226+
challengeIdsAtUnlock: string[] | null;
227+
/** The final score of the run that triggered the unlock. */
228+
runFinalScore: number;
229+
/** The seed of the run that triggered the unlock. */
230+
runSeed: string;
231+
/** ISO 8601 timestamp when the milestone was achieved. */
232+
unlockedAt: string;
233+
}
234+
217235
export interface MainStreetCampaignProgress {
236+
/** Schema version for forward-compatible deserialization. */
237+
schemaVersion: number;
238+
/** List of unlocked tier IDs, e.g. ['tier-1', 'tier-2']. Always includes 'tier-1'. */
218239
unlockedTiers: string[];
240+
/**
241+
* IDs of all cards unlocked via tier progression. Derived from unlockedTiers
242+
* at runtime, but persisted for fast lookup and offline validation.
243+
*/
244+
unlockedCardIds: string[];
245+
/**
246+
* History of milestone achievements. Each entry records when a tier was
247+
* unlocked, which trigger path was used, and the run context.
248+
*/
249+
milestoneHistory: MilestoneRecord[];
250+
/** Highest single-run reputation achieved across all runs. */
219251
persistentReputation: number;
252+
/** Highest final score achieved across all runs. */
220253
highestScore: number;
254+
/** Total number of completed runs (win or loss). */
221255
totalRuns: number;
256+
/** Total number of winning runs. */
222257
totalWins: number;
258+
/** ISO 8601 timestamp of the last update to this campaign data. */
223259
lastUpdatedAt: string;
224260
}
225261

@@ -231,6 +267,12 @@ export interface MainStreetSetupOptions {
231267
seed?: string;
232268
/** Difficulty preset name. Defaults to 'Medium' if omitted. */
233269
difficulty?: DifficultyName;
270+
/**
271+
* Card IDs unlocked via campaign tier progression. When provided, deck builders
272+
* filter templates to include only cards whose IDs are in this list. When omitted,
273+
* the full card pool is used (non-campaign / backward-compatible mode).
274+
*/
275+
unlockedCardIds?: string[];
234276
}
235277

236278
// ── Seed Helpers ────────────────────────────────────────────
@@ -302,9 +344,9 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS
302344
const config = getPreset(options.difficulty);
303345

304346
// Create and shuffle decks
305-
const businessDeck = createBusinessDeck();
306-
const eventDeck = createEventDeck();
307-
const upgradeDeck = createUpgradeDeck();
347+
const businessDeck = createBusinessDeck(3, options.unlockedCardIds);
348+
const eventDeck = createEventDeck(3, options.unlockedCardIds);
349+
const upgradeDeck = createUpgradeDeck(2, options.unlockedCardIds);
308350

309351
shuffleArray(businessDeck, rng);
310352
shuffleArray(eventDeck, rng);

0 commit comments

Comments
 (0)