Skip to content

Commit 9458739

Browse files
author
Sorra
committed
Merge wl-CG-0MMLR38NJ1N11DOS-reputation-coin-multiplier: Reputation-based coin multiplier and positive-incident-multiplier
2 parents 5b53b3a + 587897e commit 9458739

18 files changed

Lines changed: 635 additions & 43 deletions

docs/main-street/prd-milestone-2.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ loadCampaignProgress(store) -> campaign (or createDefaultCampaignProgress())
612612
|
613613
v
614614
createBusinessDeck(3, campaign.unlockedCardIds)
615-
createEventDeck(3, campaign.unlockedCardIds)
615+
createEventDeck(3, campaign.unlockedCardIds, createSeededRng(42) /* rng is required for deterministic fractional allocation; use createSeededRng(seed) in tests/runtime */)
616616
createUpgradeDeck(2, campaign.unlockedCardIds)
617617
|
618618
v

example-games/main-street/MainStreetAdjacency.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { BusinessCard, SynergyType } from './MainStreetCards';
1212
import { GRID_SIZE, SYNERGY_BONUS_PER_NEIGHBOR } from './MainStreetCards';
1313
import type { MainStreetState } from './MainStreetState';
1414
import { addLog } from './MainStreetState';
15+
import { applyReputationMultiplier } from './MainStreetDifficulty';
1516

1617
// ── Adjacency Resolver ──────────────────────────────────────
1718

@@ -142,14 +143,23 @@ export function computeIncome(
142143
* Mutates state in-place. Uses config.synergyBonusPerNeighbor from the
143144
* active difficulty preset.
144145
*
146+
* Income is scaled by the reputation coin multiplier (CG-0MMLR38NJ1N11DOS)
147+
* so that higher reputation yields proportionally more income.
148+
*
145149
* @param state Current game state (mutated).
146-
* @returns The IncomeResult for UI display.
150+
* @returns The IncomeResult for UI display (pre-multiplier breakdown,
151+
* but total reflects the multiplied amount actually credited).
147152
*/
148153
export function applyIncome(state: MainStreetState): IncomeResult {
149154
const result = computeIncome(state.streetGrid, state.config.synergyBonusPerNeighbor);
150-
state.resourceBank.coins += result.total;
151-
if (result.total > 0) {
152-
addLog(state, `Income: +${result.total} coins`, 'gain');
155+
const multiplied = applyReputationMultiplier(
156+
result.total,
157+
state.resourceBank.reputation,
158+
state.config,
159+
);
160+
state.resourceBank.coins += multiplied;
161+
if (multiplied > 0) {
162+
addLog(state, `Income: +${multiplied} coins`, 'gain');
153163
} else {
154164
addLog(state, `Income: +0 coins`, 'neutral');
155165
}

example-games/main-street/MainStreetCards.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -887,20 +887,90 @@ export function createBusinessDeck(
887887
* When provided, only templates whose ID is in this list
888888
* are included. When omitted, the full pool is used.
889889
*/
890+
/**
891+
* Creates the full Event deck for a game.
892+
*
893+
* Supports an optional `positiveIncidentMultiplier` to increase the
894+
* relative frequency of positive Incident events by duplicating positive
895+
* Incident templates before deck assembly. This keeps selection deterministic
896+
* under the seeded RNG used throughout Main Street while allowing tuning
897+
* without changing core selection logic.
898+
*
899+
* @param copies Number of copies per template (default 3).
900+
* @param unlockedCardIds Optional list of unlocked card IDs for tier filtering.
901+
* @param positiveIncidentMultiplier Multiplier applied to positive Incident templates (>=1).
902+
*/
890903
export function createEventDeck(
891904
copies: number = 3,
892-
unlockedCardIds?: string[],
905+
unlockedCardIds: string[] | undefined,
906+
rng: () => number,
907+
positiveIncidentMultiplier: number = 1,
893908
): EventCard[] {
894909
const templates = unlockedCardIds
895910
? EVENT_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id))
896911
: EVENT_TEMPLATES;
897912

913+
// If multiplier > 1, positive Incident templates should appear more often.
914+
// Implement fractional multipliers deterministically without introducing
915+
// a seeded RNG dependency: we give every positive Incident template
916+
// `baseDup = floor(multiplier)` repeats, then distribute the fractional
917+
// remainder by granting one extra repeat to `extraCount` templates. The
918+
// selection is deterministic (first N positive templates in template
919+
// order) so behavior is stable across runs.
898920
const deck: EventCard[] = [];
899-
for (let c = 0; c < copies; c++) {
900-
for (const template of templates) {
901-
deck.push({ ...template, id: `${template.id}-${c}` });
921+
let serial = 0;
922+
923+
const mult = Math.max(1, positiveIncidentMultiplier);
924+
const baseDup = Math.floor(mult);
925+
const fraction = mult - baseDup;
926+
927+
// Identify positions (indices) of positive Incident templates in the
928+
// `templates` array so we can select which ones receive the fractional
929+
// extra duplicates.
930+
const positiveIndices: number[] = [];
931+
for (let i = 0; i < templates.length; i++) {
932+
const t = templates[i];
933+
if (t.trigger === 'Incident' && (t.coinDelta + t.reputationDelta) > 0) {
934+
positiveIndices.push(i);
935+
}
936+
}
937+
938+
const positiveCount = positiveIndices.length;
939+
const extraCount = Math.round(fraction * positiveCount);
940+
941+
// Decide which positive template indices receive the extra +1 duplicate.
942+
// Always use the provided seeded RNG to shuffle and choose extraCount
943+
// indices. This makes the fractional distribution deterministic per-game
944+
// seed and removes order bias.
945+
const extraSet = new Set<number>();
946+
if (extraCount > 0 && positiveCount > 0) {
947+
// Shuffle a copy of positiveIndices using Fisher-Yates with provided RNG
948+
const idxs = positiveIndices.slice();
949+
for (let i = idxs.length - 1; i > 0; i--) {
950+
const j = Math.floor(rng() * (i + 1));
951+
const tmp = idxs[i]; idxs[i] = idxs[j]; idxs[j] = tmp;
902952
}
953+
for (let k = 0; k < extraCount; k++) extraSet.add(idxs[k]);
903954
}
955+
956+
// Iterate templates and assign duplicates. For positive templates, add
957+
// `baseDup` plus 1 if the template's index is in extraSet. For all others, use 1.
958+
for (let i = 0; i < templates.length; i++) {
959+
const template = templates[i];
960+
const net = template.coinDelta + template.reputationDelta;
961+
const isPositiveIncident = template.trigger === 'Incident' && net > 0;
962+
let dupCount = 1;
963+
if (isPositiveIncident) {
964+
dupCount = baseDup + (extraSet.has(i) ? 1 : 0);
965+
}
966+
967+
const repeat = copies * dupCount;
968+
for (let r = 0; r < repeat; r++) {
969+
deck.push({ ...template, id: `${template.id}-${serial}` });
970+
serial += 1;
971+
}
972+
}
973+
904974
return deck;
905975
}
906976

example-games/main-street/MainStreetDifficulty.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ export interface GameConfig extends DifficultyConfig {
7878
// ── Challenges ──────────────────────────────────────────
7979
/** Number of challenges selected per run. */
8080
readonly challengesPerRun: number;
81+
/** Multiplier to increase positive Incident frequency (1.0 = baseline). */
82+
readonly positiveIncidentMultiplier: number;
83+
84+
// ── Reputation-based Coin Multiplier ───────────────────
85+
/**
86+
* Divisor used in the reputation coin multiplier formula:
87+
* multiplier = 1 + (reputation / reputationCoinDivisor)
88+
*
89+
* Higher values make reputation less impactful on coin rewards.
90+
* Default 20 means rep=20 yields a 2x multiplier.
91+
*/
92+
readonly reputationCoinDivisor: number;
93+
/**
94+
* Maximum value the reputation coin multiplier can reach.
95+
* Prevents runaway scaling in long or lucky games.
96+
* Default 3.0 means coin rewards can at most triple.
97+
*/
98+
readonly maxReputationCoinMultiplier: number;
8199
}
82100

83101
// ── Preset Definitions ──────────────────────────────────────
@@ -96,6 +114,9 @@ export const EASY_PRESET: Readonly<GameConfig> = {
96114
challengeBonusPoints: 15,
97115
synergyBonusPerNeighbor: 2,
98116
challengesPerRun: 2,
117+
positiveIncidentMultiplier: 1.2,
118+
reputationCoinDivisor: 20,
119+
maxReputationCoinMultiplier: 3.0,
99120
};
100121

101122
/**
@@ -112,6 +133,11 @@ export const MEDIUM_PRESET: Readonly<GameConfig> = {
112133
challengeBonusPoints: 10,
113134
synergyBonusPerNeighbor: 1,
114135
challengesPerRun: 3,
136+
// Increase positive incident frequency by 50% for the Medium baseline
137+
// as requested by work item CG-0MMLR20XP1IPPD03.
138+
positiveIncidentMultiplier: 1.5,
139+
reputationCoinDivisor: 20,
140+
maxReputationCoinMultiplier: 3.0,
115141
};
116142

117143
/**
@@ -128,6 +154,9 @@ export const HARD_PRESET: Readonly<GameConfig> = {
128154
challengeBonusPoints: 8,
129155
synergyBonusPerNeighbor: 1,
130156
challengesPerRun: 4,
157+
positiveIncidentMultiplier: 1,
158+
reputationCoinDivisor: 20,
159+
maxReputationCoinMultiplier: 3.0,
131160
};
132161

133162
// ── Preset Registry ─────────────────────────────────────────
@@ -156,3 +185,57 @@ export function getPreset(name: DifficultyName | undefined): Readonly<GameConfig
156185

157186
/** All available difficulty names in display order. */
158187
export const DIFFICULTY_NAMES: readonly DifficultyName[] = ['Easy', 'Medium', 'Hard'];
188+
189+
// ── Reputation-based Coin Multiplier ────────────────────────
190+
191+
/**
192+
* Computes the reputation-based coin multiplier.
193+
*
194+
* Formula: min(1 + reputation / divisor, cap)
195+
*
196+
* Uses the additive formula so that reputation=0 still yields a 1x
197+
* baseline (no reward lost). The multiplier is capped to prevent
198+
* runaway scaling in long or lucky games.
199+
*
200+
* - reputation=0 -> 1.0x (baseline preserved)
201+
* - reputation=10 -> 1.5x (with divisor=20)
202+
* - reputation=20 -> 2.0x
203+
* - reputation=40 -> 3.0x (capped at maxMultiplier=3.0)
204+
* - reputation=60 -> 3.0x (capped)
205+
*
206+
* Negative reputation clamps the multiplier at 1.0 (no penalty via
207+
* this channel -- reputation collapse is handled elsewhere).
208+
*
209+
* @param reputation Current player reputation.
210+
* @param config Game config with reputationCoinDivisor and maxReputationCoinMultiplier.
211+
* @returns The multiplier to apply to coin rewards (always >= 1.0).
212+
*/
213+
export function reputationCoinMultiplier(
214+
reputation: number,
215+
config: Pick<GameConfig, 'reputationCoinDivisor' | 'maxReputationCoinMultiplier'>,
216+
): number {
217+
if (!Number.isFinite(reputation) || reputation <= 0) return 1.0;
218+
const raw = 1 + reputation / config.reputationCoinDivisor;
219+
return Math.min(raw, config.maxReputationCoinMultiplier);
220+
}
221+
222+
/**
223+
* Applies the reputation coin multiplier to a raw coin delta.
224+
*
225+
* Only positive coin deltas are scaled -- negative deltas (penalties)
226+
* pass through unchanged so that reputation does not amplify losses.
227+
* The result is rounded down (floored) to keep coin amounts integral.
228+
*
229+
* @param rawCoinDelta The base coin amount (positive = gain, negative = penalty).
230+
* @param reputation Current player reputation.
231+
* @param config Game config with multiplier tuning constants.
232+
* @returns The adjusted coin delta.
233+
*/
234+
export function applyReputationMultiplier(
235+
rawCoinDelta: number,
236+
reputation: number,
237+
config: Pick<GameConfig, 'reputationCoinDivisor' | 'maxReputationCoinMultiplier'>,
238+
): number {
239+
if (rawCoinDelta <= 0) return rawCoinDelta;
240+
return Math.floor(rawCoinDelta * reputationCoinMultiplier(reputation, config));
241+
}

example-games/main-street/MainStreetEngine.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import {
2828
type PurchaseResult,
2929
} from './MainStreetMarket';
3030
import { evaluateChallenges } from './MainStreetChallenges';
31+
import { applyReputationMultiplier, reputationCoinMultiplier } from './MainStreetDifficulty';
32+
33+
// Re-export for convenience (tests import from the engine module).
34+
export { reputationCoinMultiplier, applyReputationMultiplier };
3135

3236
// ── Action Types ────────────────────────────────────────────
3337

@@ -199,21 +203,29 @@ function classifyEffect(coinChange: number, repChange: number): 'gain' | 'loss'
199203
* SpecificSynergy events apply their coinDelta to each matching business
200204
* (simplified: apply delta once per matching placed business).
201205
* All/other events apply the delta directly to the resource bank.
206+
*
207+
* Positive coin deltas are scaled by the reputation coin multiplier
208+
* (CG-0MMLR38NJ1N11DOS). Negative deltas (penalties) pass through
209+
* unchanged.
202210
*/
203211
export function resolveEvent(state: MainStreetState, event: EventCard): void {
212+
const rep = state.resourceBank.reputation;
213+
const cfg = state.config;
214+
204215
switch (event.target) {
205216
case 'SpecificSynergy': {
206217
// Count matching businesses and apply coinDelta per match
207218
const matchCount = state.streetGrid.filter(
208219
b => b !== null && b.synergyTypes.includes(event.targetSynergy as SynergyType),
209220
).length;
210-
state.resourceBank.coins += event.coinDelta * matchCount;
221+
const rawDelta = event.coinDelta * matchCount;
222+
state.resourceBank.coins += applyReputationMultiplier(rawDelta, rep, cfg);
211223
state.resourceBank.reputation += event.reputationDelta;
212224
break;
213225
}
214226
case 'All': {
215227
// Apply to all -- direct delta on resource bank
216-
state.resourceBank.coins += event.coinDelta;
228+
state.resourceBank.coins += applyReputationMultiplier(event.coinDelta, rep, cfg);
217229
state.resourceBank.reputation += event.reputationDelta;
218230
break;
219231
}
@@ -225,7 +237,7 @@ export function resolveEvent(state: MainStreetState, event: EventCard): void {
225237
// Consume RNG for deterministic selection (used in future milestones)
226238
const _targetIdx = Math.floor(state.rng() * placed.length);
227239
void _targetIdx;
228-
state.resourceBank.coins += event.coinDelta;
240+
state.resourceBank.coins += applyReputationMultiplier(event.coinDelta, rep, cfg);
229241
}
230242
state.resourceBank.reputation += event.reputationDelta;
231243
break;

example-games/main-street/MainStreetState.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS
345345

346346
// Create and shuffle decks
347347
const businessDeck = createBusinessDeck(3, options.unlockedCardIds);
348-
const eventDeck = createEventDeck(3, options.unlockedCardIds);
348+
// Apply positive-incident weighting from the runtime difficulty config.
349+
// Pass the game's seeded RNG into createEventDeck so fractional duplicates
350+
// are selected deterministically per-game-seed rather than by template order.
351+
const eventDeck = createEventDeck(3, options.unlockedCardIds, rng, config.positiveIncidentMultiplier);
349352
const upgradeDeck = createUpgradeDeck(2, options.unlockedCardIds);
350353

351354
shuffleArray(businessDeck, rng);
Loading

tests/main-street/adjacency.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,9 @@ describe('MainStreetAdjacency', () => {
277277
// food-1: 3 + 1 synergy = 4
278278
// food-2: 2 + 1 synergy = 3
279279
expect(result.total).toBe(7);
280-
expect(state.resourceBank.coins).toBe(coinsBefore + 7);
280+
// Reputation multiplier: rep=3 (Medium default), divisor=20 → 1 + 3/20 = 1.15
281+
// floor(7 * 1.15) = floor(8.05) = 8
282+
expect(state.resourceBank.coins).toBe(coinsBefore + 8);
281283
});
282284

283285
it('should not change coins for empty grid', () => {

tests/main-street/expanded-card-pool.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type BusinessCard,
2222
type SynergyType,
2323
} from '../../example-games/main-street/MainStreetCards';
24+
import { getPreset } from '../../example-games/main-street/MainStreetDifficulty';
2425
import { computeSynergyBonus, computeBusinessIncome } from '../../example-games/main-street/MainStreetAdjacency';
2526
import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState';
2627
import { GRID_SIZE } from '../../example-games/main-street/MainStreetCards';
@@ -44,9 +45,11 @@ function makeBiz(overrides: Partial<BusinessCard> & { name: string; synergyTypes
4445
};
4546
}
4647

47-
// Build decks once for template validation
48+
// Build decks once for template validation (use multiplier=1 to test raw templates)
49+
import { createSeededRng } from '../../src/core-engine';
50+
const _rng = createSeededRng(42);
4851
const businessDeck = createBusinessDeck(1); // 1 copy = template count
49-
const eventDeck = createEventDeck(1);
52+
const eventDeck = createEventDeck(1, undefined, _rng, 1); // template-only view
5053
const upgradeDeck = createUpgradeDeck(1);
5154

5255
// ── Template Completeness ───────────────────────────────────
@@ -417,8 +420,8 @@ describe('Expanded Card Pool: Deck Building', () => {
417420
expect(createBusinessDeck(3)).toHaveLength(51);
418421
});
419422

420-
it('event deck with 3 copies should have 51 cards', () => {
421-
expect(createEventDeck(3)).toHaveLength(51);
423+
it('event deck with 3 copies should have 51 cards', () => {
424+
expect(createEventDeck(3, undefined, _rng, 1)).toHaveLength(51);
422425
});
423426

424427
it('upgrade deck with 2 copies should have 50 cards', () => {
@@ -509,7 +512,8 @@ describe('Expanded Card Pool: Seeded Deck Resolution', () => {
509512
+ state.decks.event.length
510513
+ state.incidentQueue.length
511514
+ (state.heldEvent ? 1 : 0);
512-
expect(eventTotal).toBe(createEventDeck().length);
515+
const multiplier = getPreset(undefined).positiveIncidentMultiplier;
516+
expect(eventTotal).toBe(createEventDeck(3, undefined, _rng, multiplier).length);
513517

514518
const upgTotal = state.market.investments.filter(c => c.family === 'upgrade').length
515519
+ state.decks.upgrade.length;

0 commit comments

Comments
 (0)