From ee231fca813687b644884d0b4be3d9f0a585d82e Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:07:22 +0800 Subject: [PATCH 01/16] add wallet guard on negotiation purchase and fix trait badge display --- src/engine/types.ts | 3 ++- src/store/index.ts | 9 +++++++++ src/ui/components/location/NegotiationModal.css | 1 - src/ui/components/location/NegotiationModal.tsx | 13 ++++++++++--- src/ui/screens/GameScreen.tsx | 1 + 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/engine/types.ts b/src/engine/types.ts index 2a49272..0759595 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -404,7 +404,8 @@ export type GameEventType = | 'car_repaired' | 'car_scrapped' | 'car_towed' - | 'engine_replaced'; + | 'engine_replaced' + | 'info'; export interface GameEvent { type: GameEventType; diff --git a/src/store/index.ts b/src/store/index.ts index 7efda29..dd30154 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -917,6 +917,15 @@ export const useGameStore = create()( } const finalPrice = activeNegotiation.acceptedPrice ?? listing.askingPrice; + + if (newState.player.money < finalPrice) { + set({ gameState: newState, activeNegotiation: null, pendingEvents: [ + ...timeOutcome.events, + { type: 'info' as const, message: "You can't afford this." }, + ]}); + return; + } + const actionCount = newState.history.actions.length; const rng = new RNG(newState.meta.rngSeed + newState.time.currentDay * 1000 + actionCount); diff --git a/src/ui/components/location/NegotiationModal.css b/src/ui/components/location/NegotiationModal.css index c846726..9e56af8 100644 --- a/src/ui/components/location/NegotiationModal.css +++ b/src/ui/components/location/NegotiationModal.css @@ -63,7 +63,6 @@ background: rgba(255, 255, 255, 0.10); border: 1px solid rgba(255, 255, 255, 0.15); color: var(--text-2); - text-transform: capitalize; } /* Info rows */ diff --git a/src/ui/components/location/NegotiationModal.tsx b/src/ui/components/location/NegotiationModal.tsx index 5cbee18..c3ef888 100644 --- a/src/ui/components/location/NegotiationModal.tsx +++ b/src/ui/components/location/NegotiationModal.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; import type { NegotiationState } from '@engine/types'; -import { getCarDefinition } from '@engine/index'; +import { getCarDefinition, getTraitDefinition } from '@engine/index'; import './NegotiationModal.css'; interface NegotiationModalProps { negotiation: NegotiationState; + playerMoney: number; onSubmitOffer: (price: number) => void; onAcceptListPrice: () => void; onWalkAway: () => void; @@ -12,6 +13,7 @@ interface NegotiationModalProps { export function NegotiationModal({ negotiation, + playerMoney, onSubmitOffer, onAcceptListPrice, onWalkAway, @@ -49,7 +51,7 @@ export function NegotiationModal({ {negotiation.npc.revealedTraits.length > 0 && (
{negotiation.npc.revealedTraits.map((traitId) => ( - {traitId} + {getTraitDefinition(traitId).name} ))}
)} @@ -110,7 +112,12 @@ export function NegotiationModal({ {!isOver && ( <> - diff --git a/src/ui/screens/GameScreen.tsx b/src/ui/screens/GameScreen.tsx index d7201f7..b8e591a 100644 --- a/src/ui/screens/GameScreen.tsx +++ b/src/ui/screens/GameScreen.tsx @@ -603,6 +603,7 @@ export function GameScreen({ {activeNegotiation && ( Date: Wed, 25 Mar 2026 00:12:12 +0800 Subject: [PATCH 02/16] fix low DPI map card cutoff by removing overflow:hidden from card --- src/ui/components/map/LocationCard.css | 3 ++- src/ui/components/map/LocationList.css | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/components/map/LocationCard.css b/src/ui/components/map/LocationCard.css index 6c0100d..18f233c 100644 --- a/src/ui/components/map/LocationCard.css +++ b/src/ui/components/map/LocationCard.css @@ -10,7 +10,6 @@ box-shadow: var(--glass-2-shadow); cursor: pointer; transition: all 0.2s ease; - overflow: hidden; display: flex; flex-direction: column; position: relative; @@ -52,6 +51,7 @@ background-position: center; position: relative; flex-shrink: 0; + border-radius: var(--glass-2-radius) var(--glass-2-radius) 0 0; } .loc__photo::after { @@ -59,6 +59,7 @@ position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 30%, rgba(0,0,0,0.75) 100%); + border-radius: inherit; } /* Body */ diff --git a/src/ui/components/map/LocationList.css b/src/ui/components/map/LocationList.css index 5fea45b..6c2f88f 100644 --- a/src/ui/components/map/LocationList.css +++ b/src/ui/components/map/LocationList.css @@ -5,9 +5,10 @@ .map-body { flex: 1; min-width: 0; + min-height: 0; overflow-y: auto; display: grid; - grid-template-columns: repeat(auto-fill, minmax(390px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(390px, 100%), 1fr)); grid-auto-rows: auto; gap: 10px; justify-content: start; From 948e2892a723eb05fdb5d35996f5f86526d6712a Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:21:48 +0800 Subject: [PATCH 03/16] add debug panel with money setter for testing --- src/store/index.ts | 17 ++++++++ src/ui/components/common/DebugPanel.css | 53 +++++++++++++++++++++++++ src/ui/components/common/DebugPanel.tsx | 36 +++++++++++++++++ src/ui/components/common/index.ts | 1 + src/ui/screens/GameScreen.tsx | 4 +- 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/ui/components/common/DebugPanel.css create mode 100644 src/ui/components/common/DebugPanel.tsx diff --git a/src/store/index.ts b/src/store/index.ts index dd30154..d3e3dae 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -43,6 +43,9 @@ export type AudioEvent = 'activity_end' | 'travel'; type GameTab = 'location' | 'map'; +// Debug mode — set to true to enable the in-game debug panel +const DEBUG_MODE = true; + interface GameStore { // State gameState: GameState | null; @@ -69,6 +72,9 @@ interface GameStore { muted: boolean; audioEvent: AudioEvent | null; + // Debug + debugMode: boolean; + // Actions newGame: (playerName: string, statAllocation: StatAllocation) => void; loadGame: (saveId: string) => Promise; @@ -116,6 +122,9 @@ interface GameStore { // Error handling setError: (error: string | null) => void; clearError: () => void; + + // Debug + debugSetMoney: (amount: number) => void; } type Screen = 'main_menu' | 'new_game' | 'load_game' | 'game' | 'game_over' | 'victory'; @@ -328,6 +337,7 @@ export const useGameStore = create()( activeNegotiation: null, muted: false, audioEvent: null, + debugMode: DEBUG_MODE, // Actions newGame: (playerName, statAllocation) => { @@ -1002,6 +1012,13 @@ export const useGameStore = create()( triggerAudioEvent: (event) => set({ audioEvent: event }), clearAudioEvent: () => set({ audioEvent: null }), + // Debug + debugSetMoney: (amount) => { + const { gameState } = get(); + if (!gameState) return; + set({ gameState: { ...gameState, player: { ...gameState.player, money: amount } } }); + }, + // Error handling setError: (error) => set({ error }), clearError: () => set({ error: null }), diff --git a/src/ui/components/common/DebugPanel.css b/src/ui/components/common/DebugPanel.css new file mode 100644 index 0000000..099aaa2 --- /dev/null +++ b/src/ui/components/common/DebugPanel.css @@ -0,0 +1,53 @@ +.debug-panel { + position: fixed; + bottom: 10px; + left: 10px; + z-index: 999; + background: rgba(0, 0, 0, 0.85); + border: 1px solid rgba(255, 255, 0, 0.3); + border-radius: 6px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75rem; + color: #ff0; +} + +.debug-panel__label { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.debug-panel__input { + width: 80px; + padding: 3px 6px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + color: #fff; + font-family: inherit; + font-size: 0.75rem; + -moz-appearance: textfield; +} + +.debug-panel__input::-webkit-outer-spin-button, +.debug-panel__input::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.debug-panel__btn { + padding: 3px 8px; + background: rgba(255, 255, 0, 0.15); + border: 1px solid rgba(255, 255, 0, 0.3); + border-radius: 3px; + color: #ff0; + font-family: inherit; + font-size: 0.75rem; + cursor: pointer; +} + +.debug-panel__btn:hover { + background: rgba(255, 255, 0, 0.25); +} diff --git a/src/ui/components/common/DebugPanel.tsx b/src/ui/components/common/DebugPanel.tsx new file mode 100644 index 0000000..f3bf7e0 --- /dev/null +++ b/src/ui/components/common/DebugPanel.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { useGameStore } from '@store/index'; +import './DebugPanel.css'; + +export function DebugPanel() { + const debugMode = useGameStore((s) => s.debugMode); + const debugSetMoney = useGameStore((s) => s.debugSetMoney); + const money = useGameStore((s) => s.gameState?.player.money ?? 0); + const [moneyInput, setMoneyInput] = useState(''); + + if (!debugMode) return null; + + const handleSetMoney = () => { + const val = parseInt(moneyInput, 10); + if (!isNaN(val)) { + debugSetMoney(val); + setMoneyInput(''); + } + }; + + return ( +
+ Debug + Money: ${money} + setMoneyInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSetMoney()} + placeholder="Set $" + /> + +
+ ); +} diff --git a/src/ui/components/common/index.ts b/src/ui/components/common/index.ts index ee68822..bf9c738 100644 --- a/src/ui/components/common/index.ts +++ b/src/ui/components/common/index.ts @@ -6,3 +6,4 @@ export { PauseMenu } from './PauseMenu'; export { LoadGameDialog } from './LoadGameDialog'; export { BackgroundSlideshow } from './BackgroundSlideshow'; export { NewspaperModal } from './NewspaperModal'; +export { DebugPanel } from './DebugPanel'; diff --git a/src/ui/screens/GameScreen.tsx b/src/ui/screens/GameScreen.tsx index b8e591a..a402696 100644 --- a/src/ui/screens/GameScreen.tsx +++ b/src/ui/screens/GameScreen.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useGameStore } from '@store/index'; import { LocationList, TravelConfirmModal } from '@ui/components/map'; import { ActivityCard, ActivityModal, CarCard, CarSelector, BrowseResultsModal, SleepModal, ChillModal, NegotiationModal } from '@ui/components/location'; -import { PauseMenu, ToastContainer, NewspaperModal } from '@ui/components/common'; +import { PauseMenu, ToastContainer, NewspaperModal, DebugPanel } from '@ui/components/common'; import { getLocationActivities, getLocationAtPosition, @@ -686,6 +686,8 @@ export function GameScreen({ {/* Toast notifications */} + + {/* Pause menu */} {showPauseMenu && ( Date: Wed, 25 Mar 2026 00:44:39 +0800 Subject: [PATCH 04/16] increase traitVisibilityPerPoint from 0.01 to 0.10 so badges appear --- data/economy.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/economy.json b/data/economy.json index 3ce7308..a334d10 100644 --- a/data/economy.json +++ b/data/economy.json @@ -83,7 +83,7 @@ "statEffects": { "charisma": { - "traitVisibilityPerPoint": 0.01, + "traitVisibilityPerPoint": 0.10, "counterOfferShiftPerPoint": 0.01, "adResponseBonusPerPoint": 0.01, "earningsBonusPerPoint": 0.01 From 6d9edef5e97936fab9ea9743bb3688f0ecfdcc4a Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:44:45 +0800 Subject: [PATCH 05/16] fix asking price mismatch: use listing askingPrice not NPC targetPrice --- src/engine/systems/negotiation.ts | 3 ++- src/engine/types.ts | 1 + src/ui/components/location/NegotiationModal.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/engine/systems/negotiation.ts b/src/engine/systems/negotiation.ts index 9ed79a8..2bfce3e 100644 --- a/src/engine/systems/negotiation.ts +++ b/src/engine/systems/negotiation.ts @@ -202,6 +202,7 @@ export function startNegotiation( id: listingId, carId: listing.carId, marketValue, + askingPrice: listing.askingPrice, }, history: [], status: 'active', @@ -329,7 +330,7 @@ export function acceptListPrice( return { ...negotiation, status: 'accepted', - acceptedPrice: negotiation.npc.targetPrice, + acceptedPrice: negotiation.item.askingPrice, }; } diff --git a/src/engine/types.ts b/src/engine/types.ts index 0759595..63ffae7 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -243,6 +243,7 @@ export interface NegotiationItem { id: string; // listing ID carId: string; marketValue: number; + askingPrice: number; } export interface NegotiationOffer { diff --git a/src/ui/components/location/NegotiationModal.tsx b/src/ui/components/location/NegotiationModal.tsx index c3ef888..4cf1c68 100644 --- a/src/ui/components/location/NegotiationModal.tsx +++ b/src/ui/components/location/NegotiationModal.tsx @@ -24,7 +24,7 @@ export function NegotiationModal({ const carName = carDef ? `${carDef.year} ${carDef.make} ${carDef.model}` : 'Vehicle'; const lastRound = negotiation.history[negotiation.history.length - 1]; - const lastCounterPrice = lastRound?.npcResponse.counterOffer?.price ?? negotiation.npc.targetPrice; + const lastCounterPrice = lastRound?.npcResponse.counterOffer?.price ?? negotiation.item.askingPrice; const lastDialogue = lastRound?.npcResponse.dialogue ?? `"What'll it be?"`; const isOver = negotiation.status !== 'active'; From cdda8ff277554d50946dccf52c24fcc6edd2375e Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:44:50 +0800 Subject: [PATCH 06/16] fix NPC regeneration: use listing-based RNG seed for stable NPC per car --- src/store/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index d3e3dae..e18396e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -857,8 +857,12 @@ export const useGameStore = create()( const { gameState } = get(); if (!gameState) return; - const actionCount = gameState.history.actions.length; - const rng = new RNG(gameState.meta.rngSeed + gameState.time.currentDay * 1000 + actionCount); + // Listing-based seed: same listing always produces the same NPC + let hash = 0; + for (let i = 0; i < listingId.length; i++) { + hash = ((hash << 5) - hash + listingId.charCodeAt(i)) | 0; + } + const rng = new RNG(gameState.meta.rngSeed + Math.abs(hash)); const negotiation = engineStartNegotiation(gameState, listingId, rng); set({ activeNegotiation: negotiation }); }, From bbdf8845ce311ae6c2791fbeac9cf8498ec84941 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:53:40 +0800 Subject: [PATCH 07/16] revert traitVisibilityPerPoint to 0.01 per GDD spec --- data/economy.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/economy.json b/data/economy.json index a334d10..3ce7308 100644 --- a/data/economy.json +++ b/data/economy.json @@ -83,7 +83,7 @@ "statEffects": { "charisma": { - "traitVisibilityPerPoint": 0.10, + "traitVisibilityPerPoint": 0.01, "counterOfferShiftPerPoint": 0.01, "adResponseBonusPerPoint": 0.01, "earningsBonusPerPoint": 0.01 From 22d9a4074b21fddd8ba18056cf9a90347ea631c7 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:53:47 +0800 Subject: [PATCH 08/16] fix NPC pricing: anchor to listing askingPrice not market value --- src/engine/systems/negotiation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/systems/negotiation.ts b/src/engine/systems/negotiation.ts index 2bfce3e..80a9ee8 100644 --- a/src/engine/systems/negotiation.ts +++ b/src/engine/systems/negotiation.ts @@ -175,7 +175,7 @@ export function startNegotiation( const marketValue = carDef.marketValue[conditionRating]; const npc = generateNpc(rng); - const { targetPrice, walkAwayPrice } = calculateNpcPricing(npc.traits, marketValue, rng); + const { targetPrice, walkAwayPrice } = calculateNpcPricing(npc.traits, listing.askingPrice, rng); // Reveal traits based on charisma const config = getEconomyConfig(); From b933f7a08b4822208f25cae0b450145ee01148e4 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:53:53 +0800 Subject: [PATCH 09/16] =?UTF-8?q?remove=20listing=20on=20failed=20negotiat?= =?UTF-8?q?ion=20=E2=80=94=20one=20shot=20per=20car?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/store/index.ts b/src/store/index.ts index e18396e..e359237 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -917,6 +917,16 @@ export const useGameStore = create()( } if (activeNegotiation.status !== 'accepted') { + // Failed negotiation — remove the listing (one shot per car) + newState = { + ...newState, + market: { + ...newState.market, + currentListings: newState.market.currentListings.filter( + (l) => l.id !== activeNegotiation.item.id + ), + }, + }; set({ gameState: newState, activeNegotiation: null, pendingEvents: timeOutcome.events }); return; } From fffd1b16943332c592f37384d283615171ae2330 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:07:27 +0800 Subject: [PATCH 10/16] fix wallet guard: check affordability before acceptance, use toasts --- src/engine/types.ts | 3 +-- src/store/index.ts | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/engine/types.ts b/src/engine/types.ts index 63ffae7..cd1e752 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -405,8 +405,7 @@ export type GameEventType = | 'car_repaired' | 'car_scrapped' | 'car_towed' - | 'engine_replaced' - | 'info'; + | 'engine_replaced'; export interface GameEvent { type: GameEventType; diff --git a/src/store/index.ts b/src/store/index.ts index e359237..d4c95f4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -881,12 +881,21 @@ export const useGameStore = create()( gameState.player.stats.charisma, rng ); + // If the engine accepted but the player can't afford it, block + if (negotiation.status === 'accepted' && gameState.player.money < (negotiation.acceptedPrice ?? 0)) { + get().addToast("You can't afford this.", 'error'); + return; + } set({ activeNegotiation: negotiation }); }, acceptAtListPrice: () => { - const { activeNegotiation } = get(); - if (!activeNegotiation || activeNegotiation.status !== 'active') return; + const { gameState, activeNegotiation } = get(); + if (!gameState || !activeNegotiation || activeNegotiation.status !== 'active') return; + if (gameState.player.money < activeNegotiation.item.askingPrice) { + get().addToast("You can't afford this.", 'error'); + return; + } const accepted = acceptListPrice(activeNegotiation); set({ activeNegotiation: accepted }); get().closeNegotiation(); @@ -943,10 +952,8 @@ export const useGameStore = create()( const finalPrice = activeNegotiation.acceptedPrice ?? listing.askingPrice; if (newState.player.money < finalPrice) { - set({ gameState: newState, activeNegotiation: null, pendingEvents: [ - ...timeOutcome.events, - { type: 'info' as const, message: "You can't afford this." }, - ]}); + get().addToast("You can't afford this.", 'error'); + set({ gameState: newState, activeNegotiation: null, pendingEvents: timeOutcome.events }); return; } From 84e22ae683f3c36bdd9602b65bb49af31fe6431d Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:19:40 +0800 Subject: [PATCH 11/16] fix negotiation pricing: target=asking, clamp walkaway at 90%, fix accept button --- src/engine/systems/negotiation.ts | 16 ++++++++++------ src/ui/components/location/NegotiationModal.tsx | 6 +++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/engine/systems/negotiation.ts b/src/engine/systems/negotiation.ts index 80a9ee8..309f7eb 100644 --- a/src/engine/systems/negotiation.ts +++ b/src/engine/systems/negotiation.ts @@ -108,13 +108,13 @@ export function generateNpc(rng: RNG): GeneratedNpc { */ export function calculateNpcPricing( traitIds: string[], - marketValue: number, + anchorPrice: number, rng: RNG ): { targetPrice: number; walkAwayPrice: number } { const traits = traitIds.map((id) => getTraitDefinition(id)); - // Base: target = market + 15% markup, walkaway = market - 20% - let targetMultiplier = 1.15; + // Base: target = asking price (1.0), walkaway = 80% of asking + let targetMultiplier = 1.0; let walkAwayMultiplier = 0.80; // Apply trait modifiers @@ -136,8 +136,12 @@ export function calculateNpcPricing( const variance = (rng.random() * 0.10) - 0.05; targetMultiplier += variance; - const targetPrice = Math.round(marketValue * targetMultiplier); - const walkAwayPrice = Math.round(marketValue * walkAwayMultiplier); + const targetPrice = Math.round(anchorPrice * targetMultiplier); + // Clamp walkaway to max 90% of anchor — guarantees at least a 10% negotiation band + const walkAwayPrice = Math.min( + Math.round(anchorPrice * walkAwayMultiplier), + Math.round(anchorPrice * 0.90) + ); // Sanity: walkaway must be less than target return { @@ -340,7 +344,7 @@ export function acceptListPrice( /** * Generate a counter-offer price that moves partway toward target. - * NPC concedes a bit each round but never goes below target. + * NPC concedes a bit each round but never goes below walkaway price. */ function generateCounterOffer( negotiation: NegotiationState, diff --git a/src/ui/components/location/NegotiationModal.tsx b/src/ui/components/location/NegotiationModal.tsx index 4cf1c68..9911180 100644 --- a/src/ui/components/location/NegotiationModal.tsx +++ b/src/ui/components/location/NegotiationModal.tsx @@ -115,10 +115,10 @@ export function NegotiationModal({ )} From ddfcc0ef3dcbce81474f89c4322cfcf022c8bffe Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:29:52 +0800 Subject: [PATCH 12/16] move toasts to bottom-center with slide-up animation --- src/ui/components/common/Toast.css | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ui/components/common/Toast.css b/src/ui/components/common/Toast.css index 32034ae..d67205a 100644 --- a/src/ui/components/common/Toast.css +++ b/src/ui/components/common/Toast.css @@ -2,11 +2,12 @@ .toast-container { position: fixed; - top: 24px; - right: 24px; + bottom: 48px; + left: 50%; + transform: translateX(-50%); z-index: 10000; display: flex; - flex-direction: column; + flex-direction: column-reverse; gap: 8px; pointer-events: none; } @@ -37,11 +38,11 @@ .toast-spend { border-left: 3px solid var(--energy-color); } @keyframes toastIn { - from { opacity: 0; transform: translateX(120%); } - to { opacity: 1; transform: translateX(0); } + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } } @keyframes toastOut { - from { opacity: 1; transform: translateX(0); } - to { opacity: 0; transform: translateX(120%); } + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(20px); } } From 4584e38ef0f15bb7d5c899f366b108938d14b79e Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:29:58 +0800 Subject: [PATCH 13/16] reduce modal overlay opacity from 0.6 to 0.4 --- src/ui/components/common/NewspaperModal.css | 10 ++++++++-- src/ui/components/location/ActivityModal.css | 2 +- src/ui/components/location/BrowseResultsModal.css | 10 ++++++++-- src/ui/components/location/ChillModal.css | 2 +- src/ui/components/location/NegotiationModal.css | 2 +- src/ui/components/location/SleepModal.css | 4 ++-- src/ui/components/map/TravelConfirmModal.css | 4 ++-- 7 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/ui/components/common/NewspaperModal.css b/src/ui/components/common/NewspaperModal.css index d56df35..0223d97 100644 --- a/src/ui/components/common/NewspaperModal.css +++ b/src/ui/components/common/NewspaperModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; @@ -57,10 +57,16 @@ /* ── Scrollable content ── */ .newspaper-content { flex: 1; - padding: 20px 28px 28px; + padding: 20px 28px 0; overflow-y: auto; } +.newspaper-content::after { + content: ''; + display: block; + height: 28px; +} + .newspaper-content::-webkit-scrollbar { width: 6px; } .newspaper-content::-webkit-scrollbar-track { background: var(--scrollbar-track); } .newspaper-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } diff --git a/src/ui/components/location/ActivityModal.css b/src/ui/components/location/ActivityModal.css index 85947c5..f7fbaeb 100644 --- a/src/ui/components/location/ActivityModal.css +++ b/src/ui/components/location/ActivityModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; diff --git a/src/ui/components/location/BrowseResultsModal.css b/src/ui/components/location/BrowseResultsModal.css index d94d97f..5454e1b 100644 --- a/src/ui/components/location/BrowseResultsModal.css +++ b/src/ui/components/location/BrowseResultsModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; @@ -62,13 +62,19 @@ /* ── Content ── */ .browse-modal__content { flex: 1; - padding: 20px 28px; + padding: 20px 28px 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } +.browse-modal__content::after { + content: ''; + min-height: 20px; + flex-shrink: 0; +} + .browse-modal__content::-webkit-scrollbar { width: 6px; } .browse-modal__content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); diff --git a/src/ui/components/location/ChillModal.css b/src/ui/components/location/ChillModal.css index c0910c9..fe60424 100644 --- a/src/ui/components/location/ChillModal.css +++ b/src/ui/components/location/ChillModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; diff --git a/src/ui/components/location/NegotiationModal.css b/src/ui/components/location/NegotiationModal.css index 9e56af8..18a3eac 100644 --- a/src/ui/components/location/NegotiationModal.css +++ b/src/ui/components/location/NegotiationModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.65); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); display: flex; diff --git a/src/ui/components/location/SleepModal.css b/src/ui/components/location/SleepModal.css index 1430496..cc0d709 100644 --- a/src/ui/components/location/SleepModal.css +++ b/src/ui/components/location/SleepModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; @@ -15,7 +15,7 @@ } .sleep-backdrop--crash { - background: rgba(0, 0, 0, 0.75); + background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } diff --git a/src/ui/components/map/TravelConfirmModal.css b/src/ui/components/map/TravelConfirmModal.css index a8de104..a3411ec 100644 --- a/src/ui/components/map/TravelConfirmModal.css +++ b/src/ui/components/map/TravelConfirmModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; @@ -113,7 +113,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; From 2a5531739c139368a0537eef3963c56015984750 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:30:03 +0800 Subject: [PATCH 14/16] fix scroll container bottom padding collapse with ::after spacing --- src/ui/components/map/LocationList.css | 6 ++++++ src/ui/screens/GameScreen.css | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui/components/map/LocationList.css b/src/ui/components/map/LocationList.css index 6c2f88f..2e94f0e 100644 --- a/src/ui/components/map/LocationList.css +++ b/src/ui/components/map/LocationList.css @@ -29,3 +29,9 @@ .region-label:first-child { padding-top: 0; } + +.map-body::after { + content: ''; + grid-column: 1 / -1; + height: 10px; +} diff --git a/src/ui/screens/GameScreen.css b/src/ui/screens/GameScreen.css index 15faf22..d56dd9b 100644 --- a/src/ui/screens/GameScreen.css +++ b/src/ui/screens/GameScreen.css @@ -208,12 +208,16 @@ .body { flex: 1; overflow-y: auto; - padding: 20px; + padding: 20px 20px 0; display: flex; gap: 20px; min-height: 0; } +.body > :last-child { + margin-bottom: 20px; +} + .body::-webkit-scrollbar { width: 8px; } .body::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); } .body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } From eae5a9bcf6568ef26d9fbd1372f52463ec14d0d0 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:32:03 +0800 Subject: [PATCH 15/16] set all modal overlays to 0.25 opacity --- src/ui/components/common/NewspaperModal.css | 2 +- src/ui/components/location/ActivityModal.css | 2 +- src/ui/components/location/BrowseResultsModal.css | 2 +- src/ui/components/location/ChillModal.css | 2 +- src/ui/components/location/NegotiationModal.css | 2 +- src/ui/components/location/SleepModal.css | 4 ++-- src/ui/components/map/TravelConfirmModal.css | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ui/components/common/NewspaperModal.css b/src/ui/components/common/NewspaperModal.css index 0223d97..a49c34a 100644 --- a/src/ui/components/common/NewspaperModal.css +++ b/src/ui/components/common/NewspaperModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; diff --git a/src/ui/components/location/ActivityModal.css b/src/ui/components/location/ActivityModal.css index f7fbaeb..a54394a 100644 --- a/src/ui/components/location/ActivityModal.css +++ b/src/ui/components/location/ActivityModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; diff --git a/src/ui/components/location/BrowseResultsModal.css b/src/ui/components/location/BrowseResultsModal.css index 5454e1b..5508686 100644 --- a/src/ui/components/location/BrowseResultsModal.css +++ b/src/ui/components/location/BrowseResultsModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; diff --git a/src/ui/components/location/ChillModal.css b/src/ui/components/location/ChillModal.css index fe60424..4700aa4 100644 --- a/src/ui/components/location/ChillModal.css +++ b/src/ui/components/location/ChillModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; diff --git a/src/ui/components/location/NegotiationModal.css b/src/ui/components/location/NegotiationModal.css index 18a3eac..c459b02 100644 --- a/src/ui/components/location/NegotiationModal.css +++ b/src/ui/components/location/NegotiationModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); display: flex; diff --git a/src/ui/components/location/SleepModal.css b/src/ui/components/location/SleepModal.css index cc0d709..2357b52 100644 --- a/src/ui/components/location/SleepModal.css +++ b/src/ui/components/location/SleepModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; @@ -15,7 +15,7 @@ } .sleep-backdrop--crash { - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } diff --git a/src/ui/components/map/TravelConfirmModal.css b/src/ui/components/map/TravelConfirmModal.css index a3411ec..b2847ad 100644 --- a/src/ui/components/map/TravelConfirmModal.css +++ b/src/ui/components/map/TravelConfirmModal.css @@ -6,7 +6,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; @@ -113,7 +113,7 @@ position: fixed; inset: 0; z-index: 100; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; From 44c28274785f6eaeadb2ff1222e998257b965184 Mon Sep 17 00:00:00 2001 From: Ivan <77185900+wpinrui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:45:23 +0800 Subject: [PATCH 16/16] test: cover the negotiation money path, pricing band, and one-shot rule First automated tests in the repo. They assert the five fixes this PR claims, plus the new one-shot-per-car rule: - the wallet guard: no purchase path can drive money negative - Accept deducts exactly the asking price printed on the button - NPC pricing is anchored to listing.askingPrice, not to market value - the walkaway floor never exceeds 90% of asking, for every legal trait set - counter-offers stay inside the seller's own band - the seller is stable per listing across intervening days and actions - a failed negotiation removes the listing from the market Each test was checked against a reintroduction of the bug it covers, so a regression turns it red rather than leaving it a happy-path pass. vitest.config.ts is kept separate from vite.config.ts so test runs don't drag in the Electron plugins. Engine data is served from data/ through a window.electronAPI stub in the setup file. No new dependencies. --- src/engine/systems/negotiation.test.ts | 245 +++++++++++++++++++++++++ src/store/index.test.ts | 207 +++++++++++++++++++++ src/test/fixtures.ts | 103 +++++++++++ src/test/setup.ts | 47 +++++ vitest.config.ts | 24 +++ 5 files changed, 626 insertions(+) create mode 100644 src/engine/systems/negotiation.test.ts create mode 100644 src/store/index.test.ts create mode 100644 src/test/fixtures.ts create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/src/engine/systems/negotiation.test.ts b/src/engine/systems/negotiation.test.ts new file mode 100644 index 0000000..350bc2b --- /dev/null +++ b/src/engine/systems/negotiation.test.ts @@ -0,0 +1,245 @@ +/** + * Negotiation engine — the claims made by PR #62. + * + * Everything here is seeded: `new RNG(n)` makes NPC generation, pricing and dialogue + * fully reproducible. `createRNG()` seeds from Date.now() and must never appear in a test. + */ +import { describe, it, expect } from 'vitest'; +import { RNG } from '@engine/utils/rng'; +import type { TraitDefinition } from '@engine/data'; +import { getAllTraits, getTraitDefinition } from '@engine/data'; +import { + acceptListPrice, + calculateNpcPricing, + generateNpc, + startNegotiation, + submitOffer, +} from './negotiation'; +import { makeGameState, makeListing } from '../../test/fixtures'; + +const ANCHOR = 10_000; +const ASKING = 5_000; + +/** + * Every trait combination `generateNpc` could legally produce (2–4 compatible traits), + * plus the smaller sets, so the pricing invariants are checked exhaustively rather than + * on whichever handful of traits a few seeds happen to roll. + */ +function compatibleTraitSets(maxSize: number): string[][] { + const traits = getAllTraits(); + const sets: string[][] = []; + + const build = (start: number, current: TraitDefinition[]): void => { + sets.push(current.map((t) => t.id)); + if (current.length >= maxSize) return; + for (let i = start; i < traits.length; i++) { + const candidate = traits[i]; + const conflicts = current.some( + (s) => + s.incompatibleWith.includes(candidate.id) || + candidate.incompatibleWith.includes(s.id) + ); + if (!conflicts) build(i + 1, [...current, candidate]); + } + }; + + build(0, []); + return sets; +} + +const TRAIT_SETS = compatibleTraitSets(4); +const label = (traits: string[]) => (traits.length ? traits.join('+') : 'no traits'); + +describe('NPC pricing is anchored to the asking price (fix #2)', () => { + it('targets the asking price rather than a markup over it', () => { + // On `main` the base target multiplier was 1.15 against market value. It is now 1.0 + // against the anchor, leaving only the ±5% random variance. + for (let seed = 1; seed <= 100; seed++) { + const { targetPrice } = calculateNpcPricing([], ANCHOR, new RNG(seed)); + expect(targetPrice).toBeGreaterThanOrEqual(Math.round(ANCHOR * 0.95)); + expect(targetPrice).toBeLessThanOrEqual(Math.round(ANCHOR * 1.05)); + } + }); + + it('prices the seller off the listing, not off market value', () => { + // The fixture car is worth a few hundred dollars; the listing asks $5,000. On `main` + // the seller's target was derived from market value (≈$920) while the browse card + // showed $5,000 — two prices for one car. + const listing = makeListing({ askingPrice: ASKING }); + const state = makeGameState({ listings: [listing] }); + + for (let seed = 1; seed <= 50; seed++) { + const negotiation = startNegotiation(state, listing.id, new RNG(seed)); + + expect(negotiation.item.askingPrice).toBe(listing.askingPrice); + // Guard against a vacuous pass: the fixture must genuinely separate the two numbers. + expect(negotiation.item.marketValue).not.toBe(listing.askingPrice); + expect(negotiation.npc.targetPrice).toBeGreaterThan(negotiation.item.marketValue * 2); + } + }); +}); + +describe('the negotiation band (fix #2 walkaway clamp)', () => { + it('never lets the walkaway floor exceed 90% of the asking price, for any trait set', () => { + const cap = Math.round(ANCHOR * 0.9); + const failures: string[] = []; + + for (const traits of TRAIT_SETS) { + for (let seed = 1; seed <= 25; seed++) { + const { walkAwayPrice } = calculateNpcPricing(traits, ANCHOR, new RNG(seed)); + if (walkAwayPrice > cap) { + failures.push(`${label(traits)} @seed ${seed}: floor ${walkAwayPrice} > cap ${cap}`); + } + } + } + + expect(failures).toEqual([]); + }); + + it('never produces a degenerate band: the target always sits above the walkaway', () => { + const failures: string[] = []; + + for (const traits of TRAIT_SETS) { + for (let seed = 1; seed <= 25; seed++) { + const { targetPrice, walkAwayPrice } = calculateNpcPricing(traits, ANCHOR, new RNG(seed)); + if (targetPrice <= walkAwayPrice) { + failures.push(`${label(traits)} @seed ${seed}: target ${targetPrice} <= floor ${walkAwayPrice}`); + } + } + } + + expect(failures).toEqual([]); + }); +}); + +describe('counter-offers', () => { + it('always land inside the seller\'s own band, between walkaway and target', () => { + const listing = makeListing({ askingPrice: ASKING }); + const state = makeGameState({ listings: [listing] }); + const failures: string[] = []; + let countersSeen = 0; + + for (let seed = 1; seed <= 60; seed++) { + let negotiation = startNegotiation(state, listing.id, new RNG(seed)); + const floor = negotiation.npc.walkAwayPrice; + const target = negotiation.npc.targetPrice; + const rng = new RNG(seed + 1_000); + + // Walk a rising sequence of offers up from below the floor. + for (let round = 0; round < 8 && negotiation.status === 'active'; round++) { + const offer = Math.round(floor * (0.85 + round * 0.03)); + const result = submitOffer(negotiation, { price: offer }, 5, rng); + negotiation = result.negotiation; + + const counter = result.response.counterOffer?.price; + if (counter === undefined) continue; + countersSeen++; + if (counter < floor) { + failures.push(`seed ${seed} round ${round}: counter ${counter} below floor ${floor}`); + } + if (counter > target) { + failures.push(`seed ${seed} round ${round}: counter ${counter} above target ${target}`); + } + } + } + + expect(failures).toEqual([]); + expect(countersSeen).toBeGreaterThan(0); // the loop must actually exercise counters + }); +}); + +describe('accepting at the list price (fixes #1 and #3)', () => { + it('settles at the asking price, not at the seller\'s target price', () => { + // On `main` the button read the last counter but `acceptListPrice()` charged + // npc.targetPrice — always higher. A silent overcharge. + const listing = makeListing({ askingPrice: ASKING }); + const state = makeGameState({ listings: [listing] }); + const negotiation = startNegotiation(state, listing.id, new RNG(7)); + + // Guard against a vacuous pass: the two numbers must differ for this to prove anything. + expect(negotiation.npc.targetPrice).not.toBe(negotiation.item.askingPrice); + + const accepted = acceptListPrice(negotiation); + + expect(accepted.status).toBe('accepted'); + expect(accepted.acceptedPrice).toBe(listing.askingPrice); + }); +}); + +describe('seller generation (fixes #4 and #5)', () => { + it('returns an identical seller for an identical seed', () => { + const listing = makeListing(); + const state = makeGameState({ listings: [listing] }); + + const first = startNegotiation(state, listing.id, new RNG(99)); + const second = startNegotiation(state, listing.id, new RNG(99)); + + expect(second).toEqual(first); + }); + + it('gives every generated trait a display name distinct from its raw id', () => { + // The badge renders getTraitDefinition(id).name — this is what stops it printing + // "impatient" instead of "Impatient". + for (let seed = 1; seed <= 200; seed++) { + for (const traitId of generateNpc(new RNG(seed)).traits) { + const name = getTraitDefinition(traitId).name; + expect(name).toBeTruthy(); + expect(name).not.toBe(traitId); + } + } + }); + + it('never pairs two incompatible traits on one seller', () => { + const failures: string[] = []; + + for (let seed = 1; seed <= 200; seed++) { + const traits = generateNpc(new RNG(seed)).traits.map((id) => getTraitDefinition(id)); + for (const trait of traits) { + for (const other of traits) { + if (trait.id !== other.id && trait.incompatibleWith.includes(other.id)) { + failures.push(`seed ${seed}: ${trait.id} + ${other.id}`); + } + } + } + } + + expect(failures).toEqual([]); + }); + + it('gives every seller between 2 and 4 traits', () => { + for (let seed = 1; seed <= 200; seed++) { + const { traits } = generateNpc(new RNG(seed)); + expect(traits.length).toBeGreaterThanOrEqual(2); + expect(traits.length).toBeLessThanOrEqual(4); + } + }); +}); + +describe('trait data integrity', () => { + it('gives every trait the fields the negotiation code reads off it', () => { + const traits = getAllTraits(); + expect(traits.length).toBeGreaterThan(0); + + for (const trait of traits) { + expect(trait.id, 'trait id').toBeTruthy(); + expect(trait.name, `name for "${trait.id}"`).toBeTruthy(); + expect(Array.isArray(trait.incompatibleWith), `incompatibleWith for "${trait.id}"`).toBe(true); + expect(trait.effects, `effects for "${trait.id}"`).toBeTypeOf('object'); + } + }); + + it('only names traits that exist in incompatibleWith', () => { + // A typo here would silently stop conflicting, letting the generator pair traits + // that are meant to be mutually exclusive. + const ids = new Set(getAllTraits().map((t) => t.id)); + const dangling: string[] = []; + + for (const trait of getAllTraits()) { + for (const other of trait.incompatibleWith) { + if (!ids.has(other)) dangling.push(`${trait.id} -> "${other}"`); + } + } + + expect(dangling).toEqual([]); + }); +}); diff --git a/src/store/index.test.ts b/src/store/index.test.ts new file mode 100644 index 0000000..64cc0e2 --- /dev/null +++ b/src/store/index.test.ts @@ -0,0 +1,207 @@ +/** + * Store-level negotiation behaviour — the money path and the "one shot per car" rule. + * + * The store is exercised directly; no React, no DOM. The fixture pins meta.rngSeed, and + * `startNegotiation` seeds from rngSeed + hash(listingId), so the seller and their prices + * are identical on every run. + */ +import { describe, it, expect } from 'vitest'; +import type { NegotiationState } from '@engine/types'; +import { useGameStore } from './index'; +import { makeGameState, makeListing } from '../test/fixtures'; + +const ASKING = 5_000; + +const store = () => useGameStore.getState(); +const game = () => useGameStore.getState().gameState!; + +/** Fresh game with one listing on the market and a given wallet. */ +function setup(money: number) { + const listing = makeListing({ askingPrice: ASKING }); + useGameStore.setState({ + gameState: makeGameState({ money, listings: [listing] }), + activeNegotiation: null, + toasts: [], + }); + return listing; +} + +const toastMessages = () => store().toasts.map((t) => t.message); + +describe('the wallet guard (fix #1)', () => { + it('refuses the purchase when the player is one dollar short', () => { + const listing = setup(ASKING - 1); + + store().startNegotiation(listing.id); + store().acceptAtListPrice(); + + expect(game().player.money).toBe(ASKING - 1); + expect(game().inventory.cars).toHaveLength(0); + expect(toastMessages()).toContain("You can't afford this."); + // A refused purchase must not consume the listing. + expect(game().market.currentListings).toHaveLength(1); + }); + + it('never lets money go negative, whatever the player is holding', () => { + // On `main` closeNegotiation deducted the price with no affordability check at all. + for (let money = 0; money < ASKING; money += 250) { + const listing = setup(money); + + store().startNegotiation(listing.id); + store().acceptAtListPrice(); + + expect(game().player.money, `starting from $${money}`).toBe(money); + expect(game().player.money).toBeGreaterThanOrEqual(0); + expect(game().inventory.cars).toHaveLength(0); + } + }); + + it('refuses an offer the seller accepts but the player cannot cover', () => { + const listing = setup(ASKING); + store().startNegotiation(listing.id); + + // Any offer at or above target is accepted outright, whatever the seller's traits. + const offer = store().activeNegotiation!.npc.targetPrice * 2; + useGameStore.setState({ + gameState: { ...game(), player: { ...game().player, money: offer - 1 } }, + }); + + store().submitOffer(offer); + + expect(game().player.money).toBe(offer - 1); + expect(game().player.money).toBeGreaterThanOrEqual(0); + expect(game().inventory.cars).toHaveLength(0); + expect(toastMessages()).toContain("You can't afford this."); + // The deal is not allowed to stand: the negotiation stays open rather than settling. + expect(store().activeNegotiation!.status).toBe('active'); + }); + + it('lets the player buy with exactly the asking price, landing them on zero', () => { + const listing = setup(ASKING); + + store().startNegotiation(listing.id); + store().acceptAtListPrice(); + + expect(game().player.money).toBe(0); + expect(game().inventory.cars).toHaveLength(1); + expect(game().inventory.cars[0].carId).toBe(listing.carId); + }); +}); + +describe('the price on the button is the price you pay (fixes #2 and #3)', () => { + it('quotes the browse-card asking price inside the negotiation', () => { + const listing = setup(10_000); + + store().startNegotiation(listing.id); + + // The browse card renders listing.askingPrice; the modal renders item.askingPrice. + expect(store().activeNegotiation!.item.askingPrice).toBe(listing.askingPrice); + }); + + it('deducts exactly the number printed on the Accept button', () => { + const listing = setup(ASKING + 500); + store().startNegotiation(listing.id); + + const negotiation = store().activeNegotiation!; + const printedOnButton = negotiation.item.askingPrice; // what NegotiationModal renders + // Guard against a vacuous pass: on `main` the charge came from targetPrice, so the + // two numbers must differ for this test to prove anything. + expect(negotiation.npc.targetPrice).not.toBe(printedOnButton); + + store().acceptAtListPrice(); + + expect(game().player.money).toBe(ASKING + 500 - printedOnButton); + expect(game().inventory.cars[0].acquiredPrice).toBe(printedOnButton); + }); +}); + +describe('the seller is stable for a given car (fix #5)', () => { + it('returns the same seller even after the player has acted and days have passed', () => { + // On `main` the seed was rngSeed + day*1000 + actionCount, so any action between two + // openings produced a different seller with different traits and different prices. + // The seed is now hashed from the listing id, which no clock or action count touches — + // so the time-travel below must not change the seller at all. + const listing = setup(10_000); + + store().startNegotiation(listing.id); + const first = store().activeNegotiation!; + + useGameStore.setState({ + activeNegotiation: null, + gameState: { + ...game(), + time: { ...game().time, currentDay: 9, currentHour: 15 }, + history: { + actions: [ + { timestamp: 0, day: 1, action: 'chill', params: {}, result: 'success' }, + { timestamp: 0, day: 2, action: 'sleep', params: {}, result: 'success' }, + ], + }, + }, + }); + + store().startNegotiation(listing.id); + const second = store().activeNegotiation!; + + expect(second.npc).toEqual(first.npc); + }); +}); + +describe('one shot per car (new gameplay rule)', () => { + it('destroys the listing when the player walks away', () => { + const listing = setup(10_000); + + store().startNegotiation(listing.id); + store().closeNegotiation(); + + expect(game().market.currentListings).toHaveLength(0); + expect(store().activeNegotiation).toBeNull(); + }); + + it('destroys the listing when the seller walks away', () => { + const listing = setup(10_000); + store().startNegotiation(listing.id); + + const walkedAway: NegotiationState = { + ...store().activeNegotiation!, + status: 'walked_away', + }; + useGameStore.setState({ activeNegotiation: walkedAway }); + + store().closeNegotiation(); + + expect(game().market.currentListings).toHaveLength(0); + }); + + it('cannot be reopened once it is gone: a walked-away car is off the market for good', () => { + const listing = setup(10_000); + + store().startNegotiation(listing.id); + store().closeNegotiation(); + + expect(game().market.currentListings.find((l) => l.id === listing.id)).toBeUndefined(); + expect(() => store().startNegotiation(listing.id)).toThrow(/not found/i); + }); + + it('takes the car off the market and puts it in the garage on a completed sale', () => { + const listing = setup(10_000); + + store().startNegotiation(listing.id); + store().acceptAtListPrice(); + + expect(game().market.currentListings).toHaveLength(0); + expect(game().inventory.cars).toHaveLength(1); + }); + + it('costs an hour whether the deal closes or not', () => { + const walkedAwayListing = setup(10_000); + store().startNegotiation(walkedAwayListing.id); + store().closeNegotiation(); + expect(game().time.currentHour).toBe(7); // fixture starts at 06:00 + + const boughtListing = setup(10_000); + store().startNegotiation(boughtListing.id); + store().acceptAtListPrice(); + expect(game().time.currentHour).toBe(7); + }); +}); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..d224e4d --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,103 @@ +/** + * Deterministic test fixtures. + * + * Nothing here reads the wall clock or an unseeded RNG: `rngSeed` is fixed, so every + * NPC, price and trait roll derived from a fixture is reproducible across runs. + */ +import type { CarListing, GameState } from '@engine/types'; +import { MAX_ENERGY } from '@engine/index'; + +/** Fixed seed — see `store.startNegotiation`, which seeds from rngSeed + hash(listingId). */ +export const TEST_SEED = 12345; + +/** A cheap car: market value tops out at $2,500 excellent, $800 fair, $300 poor. */ +export const TEST_CAR_ID = 'shitbox_starter'; + +interface ListingOverrides { + id?: string; + carId?: string; + askingPrice?: number; + engine?: number; + body?: number; +} + +export function makeListing(overrides: ListingOverrides = {}): CarListing { + return { + id: overrides.id ?? 'listing-alpha', + carId: overrides.carId ?? TEST_CAR_ID, + condition: { + engine: overrides.engine ?? 50, + body: overrides.body ?? 50, + }, + askingPrice: overrides.askingPrice ?? 5000, + sellerId: 'seller-1', + expiresDay: 30, + source: 'scrapyard', + }; +} + +interface StateOverrides { + money?: number; + charisma?: number; + listings?: CarListing[]; +} + +/** + * A minimal but complete GameState: day 1, 06:00, no cars owned, no listings unless given. + * Money and charisma are the two levers the negotiation path actually reads. + */ +export function makeGameState(overrides: StateOverrides = {}): GameState { + return { + meta: { + saveId: 'test-save', + version: '0.1.0', + createdAt: 0, + lastSavedAt: 0, + rngSeed: TEST_SEED, + }, + time: { currentDay: 1, currentHour: 6, currentMinute: 0 }, + player: { + name: 'Tester', + money: overrides.money ?? 10_000, + energy: MAX_ENERGY, + position: { x: 0, y: 0 }, + stats: { + charisma: overrides.charisma ?? 5, + mechanical: 5, + fitness: 5, + knowledge: 5, + driving: 5, + }, + licenses: [], + completedCourses: [], + housing: { type: 'shitbox', propertyId: null }, + daysWithoutFood: 0, + }, + inventory: { cars: [], engineParts: 0, bodyParts: 0 }, + assets: { garage: null, workshop: null, properties: [], dealership: null }, + finance: { + savings: 0, + indexFund: { invested: 0, pendingWithdrawal: 0, withdrawalAvailableDay: 0 }, + loans: [], + }, + market: { + currentListings: overrides.listings ?? [], + playerListings: [], + auctionSchedule: [], + marketTrends: [], + }, + npcs: { renters: [], employees: [] }, + newspaper: { currentDay: 0, content: null, purchased: false }, + progression: { + totalEarnings: 0, + carsFlipped: 0, + roadTripsCompleted: 0, + totalEngagement: 0, + subscribers: 0, + highestCarValue: 0, + gtoAcquired: false, + gtoAcquiredDay: null, + }, + history: { actions: [] }, + }; +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..e023bf6 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,47 @@ +/** + * Global test setup. + * + * The engine reads its JSON data through `window.electronAPI.loadData()` (Electron IPC). + * Under Node there is no `window`, so we stand up a shim that serves the real files from + * `data/` — the tests run against the same data the game ships, not a mock of it. + * + * Zustand's persist middleware also expects `localStorage`; an in-memory shim keeps the + * store quiet and, more importantly, keeps state from leaking between test files. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { loadCarData, loadEconomyData, loadTraitsData } from '@engine/data'; + +const DATA_ROOT = path.resolve(__dirname, '../../data'); + +const electronAPI = { + loadData: (filePath: string): Promise => + Promise.resolve(JSON.parse(readFileSync(path.join(DATA_ROOT, filePath), 'utf-8'))), +}; + +const memoryStorage = (): Storage => { + const map = new Map(); + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + clear: () => map.clear(), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +}; + +Object.defineProperty(globalThis, 'window', { + value: { electronAPI, localStorage: memoryStorage() }, + writable: true, +}); +Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage(), + writable: true, +}); + +// The data cache is module-level and lazily populated; the accessors (getTraitDefinition, +// getCarDefinition, getEconomyConfig) throw or return undefined until it is filled. +await Promise.all([loadEconomyData(), loadCarData(), loadTraitsData()]); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..cf240d8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +/** + * Test config is deliberately separate from vite.config.ts: that config loads the + * Electron plugins, which would try to build the Electron main/preload bundles on + * every test run. Tests only need the path aliases. + */ +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@engine': path.resolve(__dirname, './src/engine'), + '@store': path.resolve(__dirname, './src/store'), + '@ui': path.resolve(__dirname, './src/ui'), + '@data': path.resolve(__dirname, './data'), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + setupFiles: ['./src/test/setup.ts'], + }, +});