From 5fa45130e96cabe04d5d58d2bc237abe9b08c57c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:56:06 +0000 Subject: [PATCH 1/4] Initial plan From 954cad8cd3ad1633ae9acfbcbd2319e30045bd2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:00:34 +0000 Subject: [PATCH 2/4] Initial plan: branching & multi-level upgrades Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec06743..ac41689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1268,7 +1268,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1335,7 +1334,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1346,7 +1344,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -1923,7 +1920,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2838,7 +2834,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2937,7 +2932,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", From 7958317f5191776ffde54b8572ade048a9e079eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:08:49 +0000 Subject: [PATCH 3/4] feat: branching & multi-level upgrade system for Business cards - Add optional `requiredLevel` to UpgradeCard interface (0 = base, 1 = post-upgrade) - Add optional `appliedUpgrades` tracking array to BusinessCard interface - Update `makeBusiness()` factory to initialise `appliedUpgrades: []` - Raise `maxLevel` to 2 for Bakery, Diner, Cinema, Day Spa - Add 4 branching upgrade cards (Bread Factory, Fast Food, Drive-In, Wellness Center) - Add 4 level-2 upgrade cards (Grand Bakehouse, Restaurant, Multiplex, Luxury Retreat) - Update `canPurchaseUpgrade` to enforce `requiredLevel` constraint - Update `purchaseUpgrade` to apply `requiredLevel` matching and record applied IDs - Add `getUpgradeBranchesForBusiness` helper for UI branch detection - Add `showUpgradeChoiceModal` in MainStreetScene with branch-choice modal UI - Update `onUpgradeCardClick` to trigger modal when multiple branches available - Add 18 new tests in `tests/main-street/upgrades.test.ts` - Update existing test counts (expanded-card-pool, game-state) to reflect 25 templates Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- example-games/main-street/MainStreetCards.ts | 152 ++++++- example-games/main-street/MainStreetMarket.ts | 61 ++- .../main-street/scenes/MainStreetScene.ts | 124 +++++- tests/main-street/expanded-card-pool.test.ts | 8 +- tests/main-street/game-state.test.ts | 4 +- tests/main-street/upgrades.test.ts | 381 ++++++++++++++++++ 6 files changed, 712 insertions(+), 18 deletions(-) create mode 100644 tests/main-street/upgrades.test.ts diff --git a/example-games/main-street/MainStreetCards.ts b/example-games/main-street/MainStreetCards.ts index 4fcc0da..43e259d 100644 --- a/example-games/main-street/MainStreetCards.ts +++ b/example-games/main-street/MainStreetCards.ts @@ -47,6 +47,14 @@ export interface BusinessCard { incomeBonus: number; /** Cumulative synergy range extension from applied upgrades. */ synergyRangeBonus: number; + /** + * IDs of upgrade cards that have been applied to this business instance, + * in application order. Used to enforce multi-level chain requirements and + * to prevent the same branch being applied twice. + * + * Omitting this field is treated as an empty array. + */ + appliedUpgrades?: string[]; } /** @@ -68,6 +76,13 @@ export interface EventCard { /** * An Upgrade card that enhances a specific Business card. + * + * Branching upgrades: multiple `UpgradeCard` entries may share the same + * `targetBusiness` and `requiredLevel`, giving the player a choice of which + * upgrade branch to take. + * + * Multi-level chains: set `requiredLevel > 0` so the card can only be applied + * after the business has already been upgraded that many times. */ export interface UpgradeCard { readonly family: 'upgrade'; @@ -78,6 +93,14 @@ export interface UpgradeCard { readonly incomeBonus: number; readonly synergyRangeBonus: number; readonly description: string; + /** + * Minimum business level required before this upgrade may be applied. + * 0 (default) = can be applied to the base (un-upgraded) business. + * 1 = can only be applied after the business has been upgraded once, etc. + * + * Omitting this field is equivalent to setting it to 0. + */ + readonly requiredLevel?: number; } /** Union of all card types in Main Street. */ @@ -140,14 +163,15 @@ export const CHALLENGE_BONUS_POINTS = 10; /** * Creates a fresh copy of a BusinessCard from template data. - * Mutable fields (level, incomeBonus, synergyRangeBonus) are reset. + * Mutable fields (level, incomeBonus, synergyRangeBonus, appliedUpgrades) are reset. */ -function makeBusiness(template: Omit): BusinessCard { +function makeBusiness(template: Omit): BusinessCard { return { family: 'business', level: 0, incomeBonus: 0, synergyRangeBonus: 0, + appliedUpgrades: [], ...template, }; } @@ -161,7 +185,7 @@ const BUSINESS_TEMPLATES: Omit b !== null && b.name === card.targetBusiness && b.level < b.maxLevel, + b => + b !== null && + b.name === card.targetBusiness && + b.level === requiredLevel && + b.level < b.maxLevel, ); if (!hasTarget) { - return { legal: false, reason: `No eligible ${card.targetBusiness} on the street to upgrade.` }; + return { + legal: false, + reason: `No eligible ${card.targetBusiness} on the street to upgrade (requires level ${requiredLevel}, below max level).`, + }; } return { legal: true }; @@ -286,16 +295,26 @@ export function purchaseUpgrade( const card = state.market.investments[marketIndex] as UpgradeCard; // Find the target business + const requiredLevel = card.requiredLevel ?? 0; let businessIndex: number; if (targetSlot !== undefined) { const biz = state.streetGrid[targetSlot]; - if (!biz || biz.name !== card.targetBusiness || biz.level >= biz.maxLevel) { + if ( + !biz || + biz.name !== card.targetBusiness || + biz.level !== requiredLevel || + biz.level >= biz.maxLevel + ) { throw new Error(`Business at slot ${targetSlot} is not a valid target for this upgrade.`); } businessIndex = targetSlot; } else { businessIndex = state.streetGrid.findIndex( - b => b !== null && b.name === card.targetBusiness && b.level < b.maxLevel, + b => + b !== null && + b.name === card.targetBusiness && + b.level === requiredLevel && + b.level < b.maxLevel, ); } @@ -311,6 +330,10 @@ export function purchaseUpgrade( business.level += 1; business.incomeBonus += card.incomeBonus; business.synergyRangeBonus += card.synergyRangeBonus; + if (!business.appliedUpgrades) { + business.appliedUpgrades = []; + } + business.appliedUpgrades.push(card.id); // Refill market const refilled = state.decks.upgrade.length > 0; @@ -394,3 +417,31 @@ export function getEmptySlots(state: MainStreetState): number[] { } return slots; } + +/** + * Returns all upgrade cards currently in the market that are valid for + * the business occupying `slotIndex` — i.e. cards whose `targetBusiness` + * matches and whose `requiredLevel` equals the business's current level. + * + * This is the set of upgrade *branches* the player can choose from for + * that business. When the set has more than one entry the UI should + * present an upgrade-choice modal so the player can pick a branch. + * + * @param state Current game state. + * @param slotIndex Street grid slot index of the target business. + * @returns Array of eligible UpgradeCards (may be empty or have multiple entries). + */ +export function getUpgradeBranchesForBusiness( + state: MainStreetState, + slotIndex: number, +): UpgradeCard[] { + const business = state.streetGrid[slotIndex]; + if (!business) return []; + if (business.level >= business.maxLevel) return []; + + return (state.market.investments.filter(c => c.family === 'upgrade') as UpgradeCard[]).filter( + card => + card.targetBusiness === business.name && + (card.requiredLevel ?? 0) === business.level, + ); +} diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 2660923..2ee6727 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -37,6 +37,7 @@ import { getAffordableBusinessCards, getAffordableUpgradeCards, getEmptySlots, + getUpgradeBranchesForBusiness, canPurchaseBusiness, canPurchaseUpgrade, canPurchaseEvent, @@ -1176,7 +1177,24 @@ export class MainStreetScene extends CardGameScene { return; } - const action: PlayerAction = { type: 'buy-upgrade', cardId: card.id }; + // Determine which business slot this upgrade targets (first eligible match) + const targetSlot = this.state.streetGrid.findIndex( + b => + b !== null && + b.name === card.targetBusiness && + b.level === card.requiredLevel && + b.level < b.maxLevel, + ); + + // If there are multiple upgrade branches for that business, show a choice modal + const branches = getUpgradeBranchesForBusiness(this.state, targetSlot); + if (branches.length > 1) { + this.showUpgradeChoiceModal(branches, targetSlot); + return; + } + + // Single upgrade available — apply immediately + const action: PlayerAction = { type: 'buy-upgrade', cardId: card.id, targetSlot }; try { executeAction(this.state, action); this.instructionText.setText(`Applied upgrade: "${card.name}"`); @@ -1187,6 +1205,110 @@ export class MainStreetScene extends CardGameScene { this.refreshAll(); } + /** + * Shows a modal overlay that lets the player choose between multiple + * upgrade branches available for the business at `targetSlot`. + * + * When a branch button is clicked the modal is dismissed, the chosen + * upgrade is applied via `executeAction`, and the scene is refreshed. + * + * @param branches Eligible UpgradeCards the player may choose from. + * @param targetSlot Street grid slot of the business to be upgraded. + */ + private showUpgradeChoiceModal(branches: UpgradeCard[], targetSlot: number): void { + const MODAL_DEPTH = 20; + const MODAL_W = 500; + const BTN_H = 60; + const HEADER_H = 60; + const FOOTER_H = 50; + const MODAL_H = HEADER_H + branches.length * BTN_H + FOOTER_H; + + const overlay = createOverlayBackground( + this, + { depth: MODAL_DEPTH, alpha: 0.8 }, + { width: MODAL_W, height: MODAL_H, color: 0x1a1208, alpha: 0.95, depth: MODAL_DEPTH }, + ); + this.overlayObjects.push(...overlay.objects); + + const cx = GAME_W / 2; + const cy = GAME_H / 2; + const top = cy - MODAL_H / 2; + + // Title + const title = this.add + .text(cx, top + 24, 'Choose an Upgrade Path', { + fontSize: '18px', fontStyle: 'bold', color: '#ffdd88', fontFamily: FONT_FAMILY, + }) + .setOrigin(0.5, 0.5) + .setDepth(MODAL_DEPTH + 1); + this.overlayObjects.push(title); + + // Branch buttons + branches.forEach((branch, idx) => { + const btnY = top + HEADER_H + idx * BTN_H + BTN_H / 2; + + // Button background + const btnBg = this.add.rectangle(cx, btnY, MODAL_W - 40, BTN_H - 8, 0x2a1f14, 0.9) + .setDepth(MODAL_DEPTH + 1) + .setStrokeStyle(1, 0x665544) + .setInteractive({ useHandCursor: true }); + this.overlayObjects.push(btnBg); + + // Branch label + const costLabel = `$${branch.cost}`; + const bonusLabel = `+${branch.incomeBonus} income, +${branch.synergyRangeBonus} range`; + const btnText = this.add + .text(cx, btnY - 8, branch.name, { + fontSize: '14px', fontStyle: 'bold', color: '#ffffff', fontFamily: FONT_FAMILY, + }) + .setOrigin(0.5, 0.5) + .setDepth(MODAL_DEPTH + 2); + this.overlayObjects.push(btnText); + + const detailText = this.add + .text(cx, btnY + 10, `${costLabel} — ${bonusLabel}`, { + fontSize: '11px', color: '#aaaaaa', fontFamily: FONT_FAMILY, + }) + .setOrigin(0.5, 0.5) + .setDepth(MODAL_DEPTH + 2); + this.overlayObjects.push(detailText); + + const onChoose = (): void => { + // Dismiss modal first + dismissOverlay(this.overlayObjects); + this.overlayObjects = []; + + const action: PlayerAction = { type: 'buy-upgrade', cardId: branch.id, targetSlot }; + try { + executeAction(this.state, action); + this.instructionText.setText(`Applied upgrade: "${branch.name}"`); + } catch (e) { + this.instructionText.setText(`Error: ${(e as Error).message}`); + } + this.refreshAll(); + }; + + btnBg.on('pointerdown', onChoose); + btnBg.on('pointerover', () => btnBg.setFillStyle(0x3a2f24, 0.95)); + btnBg.on('pointerout', () => btnBg.setFillStyle(0x2a1f14, 0.9)); + }); + + // Cancel button + const cancelBtn = createOverlayButton( + this, + cx, + top + MODAL_H - FOOTER_H / 2, + '[ Cancel ]', + MODAL_DEPTH + 2, + { color: '#ff8888', hoverColor: '#ffaaaa' }, + ); + this.overlayObjects.push(cancelBtn); + cancelBtn.on('pointerdown', () => { + dismissOverlay(this.overlayObjects); + this.overlayObjects = []; + }); + } + // ── Activity Log ───────────────────────────────────────── /** diff --git a/tests/main-street/expanded-card-pool.test.ts b/tests/main-street/expanded-card-pool.test.ts index c27a40f..1fbc2b5 100644 --- a/tests/main-street/expanded-card-pool.test.ts +++ b/tests/main-street/expanded-card-pool.test.ts @@ -60,8 +60,8 @@ describe('Expanded Card Pool: Template Completeness', () => { expect(eventDeck).toHaveLength(17); }); - it('should have exactly 17 upgrade templates', () => { - expect(upgradeDeck).toHaveLength(17); + it('should have exactly 25 upgrade templates', () => { + expect(upgradeDeck).toHaveLength(25); }); it('should have unique business IDs', () => { @@ -421,8 +421,8 @@ describe('Expanded Card Pool: Deck Building', () => { expect(createEventDeck(3)).toHaveLength(51); }); - it('upgrade deck with 2 copies should have 34 cards', () => { - expect(createUpgradeDeck(2)).toHaveLength(34); + it('upgrade deck with 2 copies should have 50 cards', () => { + expect(createUpgradeDeck(2)).toHaveLength(50); }); it('deck copies should have distinct IDs', () => { diff --git a/tests/main-street/game-state.test.ts b/tests/main-street/game-state.test.ts index e0481ca..81067dd 100644 --- a/tests/main-street/game-state.test.ts +++ b/tests/main-street/game-state.test.ts @@ -29,10 +29,10 @@ import { DEFAULT_CHALLENGES_PER_RUN } from '../../example-games/main-street/Main // ── Template Counts (M1 + M2) ────────────────────────────── // Business: 5 (M1) + 12 (M2) = 17 templates // Event: 5 (M1) + 12 (M2) = 17 templates -// Upgrade: 3 (M1) + 14 (M2) = 17 templates +// Upgrade: 3 (M1) + 14 (M2) + 4 branching + 4 level-2 = 25 templates const BUSINESS_TEMPLATE_COUNT = 17; const EVENT_TEMPLATE_COUNT = 17; -const UPGRADE_TEMPLATE_COUNT = 17; +const UPGRADE_TEMPLATE_COUNT = 25; const DEFAULT_BUSINESS_COPIES = 3; const DEFAULT_EVENT_COPIES = 3; const DEFAULT_UPGRADE_COPIES = 2; diff --git a/tests/main-street/upgrades.test.ts b/tests/main-street/upgrades.test.ts new file mode 100644 index 0000000..3ca9098 --- /dev/null +++ b/tests/main-street/upgrades.test.ts @@ -0,0 +1,381 @@ +/** + * Main Street: Branching & Multi-Level Upgrade Tests + * + * Validates the full upgrade lifecycle introduced by the branching / + * multi-level feature: + * + * - `requiredLevel` enforcement (canPurchaseUpgrade / purchaseUpgrade) + * - Branching upgrades: multiple level-0 paths for the same business + * - Multi-level chains: applying a level-1 upgrade after a level-0 one + * - State persistence: appliedUpgrades tracking, income/range accumulation + * - getUpgradeBranchesForBusiness helper returns correct branch sets + */ +import { describe, it, expect } from 'vitest'; + +import { setupMainStreetGame, type MainStreetState } from '../../example-games/main-street/MainStreetState'; +import { + canPurchaseUpgrade, + purchaseUpgrade, + getUpgradeBranchesForBusiness, +} from '../../example-games/main-street/MainStreetMarket'; +import type { BusinessCard, UpgradeCard } from '../../example-games/main-street/MainStreetCards'; + +// ── Helpers ───────────────────────────────────────────────── + +function createTestState(seed: string = 'upgrades-test'): MainStreetState { + return setupMainStreetGame({ seed }); +} + +/** + * Builds a minimal BusinessCard with all required mutable fields set + * to their starting values. + */ +function makeBiz(overrides: Partial = {}): BusinessCard { + return { + family: 'business', + id: 'biz-bakery-test', + name: 'Bakery', + cost: 3, + baseIncome: 2, + synergyTypes: ['Food'], + upgradePath: 'Bakery', + maxLevel: 2, + description: 'Test bakery.', + level: 0, + incomeBonus: 0, + synergyRangeBonus: 0, + appliedUpgrades: [], + ...overrides, + }; +} + +/** + * Builds a minimal UpgradeCard fixture. + */ +function makeUpg(overrides: Partial = {}): UpgradeCard { + return { + family: 'upgrade', + id: 'upg-patisserie-test', + name: 'Upgrade to Patisserie', + targetBusiness: 'Bakery', + cost: 4, + incomeBonus: 1, + synergyRangeBonus: 1, + requiredLevel: 0, + description: 'Test upgrade.', + ...overrides, + }; +} + +/** Injects an upgrade card into the investments row of state. */ +function injectUpgrade(state: MainStreetState, card: UpgradeCard): void { + state.market.investments.push(card); +} + +// ── requiredLevel enforcement ───────────────────────────────── + +describe('requiredLevel enforcement', () => { + it('allows a level-0 upgrade on a base (level 0) business', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ requiredLevel: 0 }); + injectUpgrade(state, upg); + + const result = canPurchaseUpgrade(state, upg.id); + expect(result.legal).toBe(true); + }); + + it('rejects a level-1 upgrade on a base (level 0) business', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ id: 'upg-grand-bakehouse-test', requiredLevel: 1, name: 'Grand Bakehouse' }); + injectUpgrade(state, upg); + + const result = canPurchaseUpgrade(state, upg.id); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('requires level 1'); + } + }); + + it('allows a level-1 upgrade after the business has been upgraded once', () => { + const state = createTestState(); + // Business already at level 1 (from a prior upgrade) + const biz = makeBiz({ level: 1 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ id: 'upg-grand-bakehouse-test', requiredLevel: 1, name: 'Grand Bakehouse' }); + injectUpgrade(state, upg); + + const result = canPurchaseUpgrade(state, upg.id); + expect(result.legal).toBe(true); + }); + + it('rejects any upgrade when business is already at maxLevel', () => { + const state = createTestState(); + // Business already at maxLevel + const biz = makeBiz({ level: 2, maxLevel: 2 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ requiredLevel: 2, id: 'upg-level2-test' }); + injectUpgrade(state, upg); + + // business.level === requiredLevel but business.level >= maxLevel + const result = canPurchaseUpgrade(state, upg.id); + expect(result.legal).toBe(false); + }); +}); + +// ── Upgrade application & state persistence ────────────────── + +describe('purchaseUpgrade state persistence', () => { + it('increments level, applies incomeBonus and synergyRangeBonus', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[3] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ incomeBonus: 1, synergyRangeBonus: 1 }); + injectUpgrade(state, upg); + + purchaseUpgrade(state, upg.id); + + expect(state.streetGrid[3]!.level).toBe(1); + expect(state.streetGrid[3]!.incomeBonus).toBe(1); + expect(state.streetGrid[3]!.synergyRangeBonus).toBe(1); + }); + + it('records the upgrade ID in appliedUpgrades', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ id: 'upg-patisserie-persist' }); + injectUpgrade(state, upg); + + purchaseUpgrade(state, upg.id); + + expect(state.streetGrid[0]!.appliedUpgrades).toContain('upg-patisserie-persist'); + }); + + it('accumulates bonuses across multiple upgrade levels', () => { + const state = createTestState(); + // Level 0 → 1 + const biz = makeBiz({ level: 0, maxLevel: 2 }); + state.streetGrid[5] = biz; + state.resourceBank.coins = 200; + + const upg0 = makeUpg({ id: 'upg-lvl0', requiredLevel: 0, incomeBonus: 1, synergyRangeBonus: 1 }); + injectUpgrade(state, upg0); + purchaseUpgrade(state, upg0.id); + + expect(state.streetGrid[5]!.level).toBe(1); + expect(state.streetGrid[5]!.incomeBonus).toBe(1); + + // Level 1 → 2 + const upg1 = makeUpg({ id: 'upg-lvl1', requiredLevel: 1, incomeBonus: 2, synergyRangeBonus: 1 }); + injectUpgrade(state, upg1); + purchaseUpgrade(state, upg1.id); + + expect(state.streetGrid[5]!.level).toBe(2); + expect(state.streetGrid[5]!.incomeBonus).toBe(3); // 1 + 2 + expect(state.streetGrid[5]!.synergyRangeBonus).toBe(2); // 1 + 1 + expect(state.streetGrid[5]!.appliedUpgrades).toEqual(['upg-lvl0', 'upg-lvl1']); + }); + + it('deducts coins for each upgrade applied', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 50; + + const upg = makeUpg({ cost: 4 }); + injectUpgrade(state, upg); + + purchaseUpgrade(state, upg.id); + + expect(state.resourceBank.coins).toBe(46); + }); + + it('targets a specific slot when targetSlot is provided', () => { + const state = createTestState(); + const biz2 = makeBiz({ id: 'biz-bakery-slot2', level: 0 }); + const biz7 = makeBiz({ id: 'biz-bakery-slot7', level: 0 }); + state.streetGrid[2] = biz2; + state.streetGrid[7] = biz7; + state.resourceBank.coins = 100; + + const upg = makeUpg({ id: 'upg-slot-test' }); + injectUpgrade(state, upg); + + purchaseUpgrade(state, upg.id, 7); + + expect(state.streetGrid[7]!.level).toBe(1); + expect(state.streetGrid[2]!.level).toBe(0); + }); + + it('throws when targetSlot does not meet requiredLevel', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); // level 0, but upgrade requires level 1 + state.streetGrid[1] = biz; + state.resourceBank.coins = 100; + + const upg = makeUpg({ id: 'upg-bad-req', requiredLevel: 1 }); + injectUpgrade(state, upg); + + expect(() => purchaseUpgrade(state, upg.id, 1)).toThrow(); + }); +}); + +// ── Branching upgrades ──────────────────────────────────────── + +describe('branching upgrades', () => { + it('getUpgradeBranchesForBusiness returns all eligible branches for a slot', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[4] = biz; + state.resourceBank.coins = 100; + + // Inject two competing level-0 upgrades for Bakery + const branch1 = makeUpg({ id: 'upg-branch-a', name: 'Branch A', requiredLevel: 0 }); + const branch2 = makeUpg({ id: 'upg-branch-b', name: 'Branch B', requiredLevel: 0 }); + injectUpgrade(state, branch1); + injectUpgrade(state, branch2); + + const branches = getUpgradeBranchesForBusiness(state, 4); + expect(branches).toHaveLength(2); + const ids = branches.map(b => b.id); + expect(ids).toContain('upg-branch-a'); + expect(ids).toContain('upg-branch-b'); + }); + + it('getUpgradeBranchesForBusiness returns empty array for an empty slot', () => { + const state = createTestState(); + // slot 9 is empty + const branches = getUpgradeBranchesForBusiness(state, 9); + expect(branches).toHaveLength(0); + }); + + it('getUpgradeBranchesForBusiness returns empty array when business is at maxLevel', () => { + const state = createTestState(); + const biz = makeBiz({ level: 1, maxLevel: 1 }); + state.streetGrid[0] = biz; + + const upg = makeUpg({ requiredLevel: 1 }); + injectUpgrade(state, upg); + + // level === maxLevel so no upgrade should be offered + const branches = getUpgradeBranchesForBusiness(state, 0); + expect(branches).toHaveLength(0); + }); + + it('only returns branches whose requiredLevel matches business level', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0 }); + state.streetGrid[2] = biz; + + const level0Upg = makeUpg({ id: 'upg-l0', requiredLevel: 0 }); + const level1Upg = makeUpg({ id: 'upg-l1', requiredLevel: 1 }); + injectUpgrade(state, level0Upg); + injectUpgrade(state, level1Upg); + + const branches = getUpgradeBranchesForBusiness(state, 2); + // Only the level-0 upgrade should be returned since business is at level 0 + expect(branches).toHaveLength(1); + expect(branches[0].id).toBe('upg-l0'); + }); + + it('applying one branch prevents the other from being applied to same business', () => { + const state = createTestState(); + const biz = makeBiz({ level: 0, maxLevel: 1 }); + state.streetGrid[0] = biz; + state.resourceBank.coins = 100; + + const branch1 = makeUpg({ id: 'upg-branch-x', requiredLevel: 0 }); + const branch2 = makeUpg({ id: 'upg-branch-y', requiredLevel: 0 }); + injectUpgrade(state, branch1); + injectUpgrade(state, branch2); + + // Apply branch1 + purchaseUpgrade(state, branch1.id, 0); + + // Business is now at level 1 (== maxLevel), so branch2 should be illegal + const result = canPurchaseUpgrade(state, branch2.id); + expect(result.legal).toBe(false); + }); +}); + +// ── Multi-level chain: real card pool templates ─────────────── + +describe('multi-level chain with real upgrade templates', () => { + it('Bakery can receive a level-0 then level-1 upgrade from the template pool', () => { + const state = createTestState(); + const bakeryTemplate = state.decks.business.find(b => b.name === 'Bakery'); + expect(bakeryTemplate).toBeDefined(); + + const biz: BusinessCard = { ...bakeryTemplate!, id: 'biz-bakery-chain', level: 0, incomeBonus: 0, synergyRangeBonus: 0, appliedUpgrades: [] }; + state.streetGrid[0] = biz; + state.resourceBank.coins = 200; + + // Find a level-0 upgrade for Bakery in the full upgrade deck + const level0Upg = state.decks.upgrade.find( + u => u.targetBusiness === 'Bakery' && u.requiredLevel === 0, + ); + expect(level0Upg).toBeDefined(); + injectUpgrade(state, level0Upg!); + + purchaseUpgrade(state, level0Upg!.id, 0); + expect(state.streetGrid[0]!.level).toBe(1); + + // Find a level-1 upgrade for Bakery in the full upgrade deck + const level1Upg = state.decks.upgrade.find( + u => u.targetBusiness === 'Bakery' && u.requiredLevel === 1, + ); + expect(level1Upg).toBeDefined(); + injectUpgrade(state, level1Upg!); + + purchaseUpgrade(state, level1Upg!.id, 0); + expect(state.streetGrid[0]!.level).toBe(2); + + // Both upgrades should be tracked + expect(state.streetGrid[0]!.appliedUpgrades).toHaveLength(2); + }); + + it('Cinema supports two branching level-0 upgrades (IMAX vs Drive-In)', () => { + const state = createTestState(); + + const imax = state.decks.upgrade.find(u => u.id.startsWith('upg-imax')); + const driveIn = state.decks.upgrade.find(u => u.id.startsWith('upg-drive-in')); + expect(imax).toBeDefined(); + expect(driveIn).toBeDefined(); + + // Both should be at requiredLevel 0 + expect(imax!.requiredLevel).toBe(0); + expect(driveIn!.requiredLevel).toBe(0); + + // Both target 'Cinema' + expect(imax!.targetBusiness).toBe('Cinema'); + expect(driveIn!.targetBusiness).toBe('Cinema'); + + // They offer different trade-offs + expect(imax!.incomeBonus).toBeGreaterThan(driveIn!.incomeBonus); + expect(driveIn!.synergyRangeBonus).toBeGreaterThan(imax!.synergyRangeBonus); + }); + + it('Day Spa level-2 upgrade (Luxury Retreat) requires level 1', () => { + const state = createTestState(); + const luxuryRetreat = state.decks.upgrade.find(u => u.id.startsWith('upg-luxury-retreat')); + expect(luxuryRetreat).toBeDefined(); + expect(luxuryRetreat!.requiredLevel).toBe(1); + expect(luxuryRetreat!.targetBusiness).toBe('Day Spa'); + }); +}); From 62c1861ded5d20b266d7d29d867fbb1779cb7c40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:11:59 +0000 Subject: [PATCH 4/4] refactor: extract findTargetBusinessSlot helper and eliminate duplicate slot-finding logic Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- example-games/main-street/MainStreetMarket.ts | 35 +++++++++++++++---- .../main-street/scenes/MainStreetScene.ts | 11 ++---- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/example-games/main-street/MainStreetMarket.ts b/example-games/main-street/MainStreetMarket.ts index 90ff131..f3d6666 100644 --- a/example-games/main-street/MainStreetMarket.ts +++ b/example-games/main-street/MainStreetMarket.ts @@ -309,13 +309,7 @@ export function purchaseUpgrade( } businessIndex = targetSlot; } else { - businessIndex = state.streetGrid.findIndex( - b => - b !== null && - b.name === card.targetBusiness && - b.level === requiredLevel && - b.level < b.maxLevel, - ); + businessIndex = findTargetBusinessSlot(state, card); } const business = state.streetGrid[businessIndex]!; @@ -418,6 +412,33 @@ export function getEmptySlots(state: MainStreetState): number[] { return slots; } +/** + * Finds the first street grid slot containing a business that is a valid + * target for `card` — i.e. the business name matches, the business level + * equals the card's `requiredLevel` (defaulting to 0), and the business is + * below its `maxLevel`. + * + * Used by both the market logic and the UI to locate the default target + * slot without duplicating the matching conditions. + * + * @param state Current game state. + * @param card The UpgradeCard to match. + * @returns The slot index of the first eligible business, or -1 if none. + */ +export function findTargetBusinessSlot( + state: MainStreetState, + card: UpgradeCard, +): number { + const requiredLevel = card.requiredLevel ?? 0; + return state.streetGrid.findIndex( + b => + b !== null && + b.name === card.targetBusiness && + b.level === requiredLevel && + b.level < b.maxLevel, + ); +} + /** * Returns all upgrade cards currently in the market that are valid for * the business occupying `slotIndex` — i.e. cards whose `targetBusiness` diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 2ee6727..d855e78 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -38,6 +38,7 @@ import { getAffordableUpgradeCards, getEmptySlots, getUpgradeBranchesForBusiness, + findTargetBusinessSlot, canPurchaseBusiness, canPurchaseUpgrade, canPurchaseEvent, @@ -1178,13 +1179,7 @@ export class MainStreetScene extends CardGameScene { } // Determine which business slot this upgrade targets (first eligible match) - const targetSlot = this.state.streetGrid.findIndex( - b => - b !== null && - b.name === card.targetBusiness && - b.level === card.requiredLevel && - b.level < b.maxLevel, - ); + const targetSlot = findTargetBusinessSlot(this.state, card); // If there are multiple upgrade branches for that business, show a choice modal const branches = getUpgradeBranchesForBusiness(this.state, targetSlot); @@ -1193,7 +1188,7 @@ export class MainStreetScene extends CardGameScene { return; } - // Single upgrade available — apply immediately + // Single upgrade available — apply immediately with the resolved slot const action: PlayerAction = { type: 'buy-upgrade', cardId: card.id, targetSlot }; try { executeAction(this.state, action);