diff --git a/components/HiddenCostGame.tsx b/components/HiddenCostGame.tsx index 0ff905b..affd1dc 100644 --- a/components/HiddenCostGame.tsx +++ b/components/HiddenCostGame.tsx @@ -15,7 +15,7 @@ import { } from "@/utils/game"; import { isPostRevealSurveyComplete } from "@/utils/researchMetrics"; import { getStoredSession, saveStoredSession } from "@/utils/session"; -import type { GameChoice, HiddenCostGameState, ReplayGameState, ResearchSession } from "@/types/research"; +import type { GameChoice, GameRoundData, HiddenCostGameState, MedicalEvent, ReplayGameState, ResearchSession } from "@/types/research"; const choiceLabels: Record = { "full-treatment": "Full treatment", @@ -23,14 +23,40 @@ const choiceLabels: Record = { "skip-treatment": "Skip treatment", }; +const choiceDescriptions: Record = { + "full-treatment": "Most care now, highest cost.", + "partial-treatment": "Some care now, moderate cost.", + "skip-treatment": "No care cost now, larger health loss.", +}; + +const choiceIcon: Record = { + "full-treatment": "โค๏ธ", + "partial-treatment": "๐Ÿ’Š", + "skip-treatment": "๐Ÿ‘›", +}; + +type RoundResult = { + round: GameRoundData; + isComplete: boolean; +}; + +type ChoicePreview = { + choice: GameChoice; + paidCost: number; + scoreAfter: number; + healthAfter: number; +}; + export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "replay" }) { const router = useRouter(); const [session, setSession] = useState(null); - const [game, setGame] = useState(null); + const [game, setGame] = useState(null); const [roundStartedAt, setRoundStartedAt] = useState(() => Date.now()); + const [pendingRoundResult, setPendingRoundResult] = useState(null); useEffect(() => { const storedSession = getStoredSession(mode === "replay" ? "individual-results" : "game"); + setPendingRoundResult(null); if (mode === "replay") { if (!storedSession.game?.completedAt || !isPostRevealSurveyComplete(storedSession.postRevealSurvey)) { @@ -111,8 +137,25 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla return getTreatmentCost(currentEvent.basePartialCost, game.treatmentCostMultiplier); }, [currentEvent, game]); + const choicePreviews = useMemo(() => { + if (!game) { + return []; + } + + return (["full-treatment", "partial-treatment", "skip-treatment"] as GameChoice[]).map((choice) => { + const paidCost = getPaidCost(choice, actualFullCost, actualPartialCost); + + return { + choice, + paidCost, + scoreAfter: Number((game.financialPoints + ROUND_INCOME_POINTS - paidCost).toFixed(2)), + healthAfter: getHealthAfterChoice(choice, game.healthPoints), + }; + }); + }, [actualFullCost, actualPartialCost, game]); + function handleChoice(choice: GameChoice) { - if (!session || !game || !currentEvent) { + if (!session || !game || !currentEvent || pendingRoundResult) { return; } @@ -122,27 +165,25 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla const healthBefore = game.healthPoints; const scoreAfter = Number((scoreBefore + ROUND_INCOME_POINTS - paidCost).toFixed(2)); const healthAfter = getHealthAfterChoice(choice, healthBefore); - const updatedRounds = [ - ...game.rounds, - { - roundNumber: currentEvent.roundNumber, - eventName: currentEvent.eventName, - displayedProfile: game.displayedProfile, - hiddenProfile: game.hiddenProfile, - baseFullCost: currentEvent.baseFullCost, - basePartialCost: currentEvent.basePartialCost, - actualFullCost, - actualPartialCost, - choice, - paidCost, - scoreBefore, - scoreAfter, - healthBefore, - healthAfter, - timestamp, - decisionTimeMs: Date.now() - roundStartedAt, - }, - ]; + const roundData: GameRoundData = { + roundNumber: currentEvent.roundNumber, + eventName: currentEvent.eventName, + displayedProfile: game.displayedProfile, + hiddenProfile: game.hiddenProfile, + baseFullCost: currentEvent.baseFullCost, + basePartialCost: currentEvent.basePartialCost, + actualFullCost, + actualPartialCost, + choice, + paidCost, + scoreBefore, + scoreAfter, + healthBefore, + healthAfter, + timestamp, + decisionTimeMs: Date.now() - roundStartedAt, + }; + const updatedRounds = [...game.rounds, roundData]; const isComplete = updatedRounds.length === medicalEvents.length; const updatedGame: HiddenCostGameState | ReplayGameState = { ...game, @@ -174,12 +215,20 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla saveStoredSession(updatedSession); setSession(updatedSession); setGame(updatedGame); + setPendingRoundResult({ round: roundData, isComplete }); + } - if (isComplete) { + function handleContinue() { + if (!pendingRoundResult) { + return; + } + + if (pendingRoundResult.isComplete) { router.push(mode === "replay" ? "/individual-results" : "/visible-results"); return; } + setPendingRoundResult(null); setRoundStartedAt(Date.now()); } @@ -191,82 +240,303 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla ); } + const displayEvent = pendingRoundResult ? medicalEvents[pendingRoundResult.round.roundNumber - 1] ?? currentEvent : currentEvent; + const displayFinancialPoints = pendingRoundResult ? pendingRoundResult.round.scoreAfter : game.financialPoints; + const displayHealthPoints = pendingRoundResult ? pendingRoundResult.round.healthAfter : game.healthPoints; + return ( - {mode === "replay" ? ( -
-

This is an optional second playthrough.

-

No additional survey questions will be asked. Your choices in this replay are saved separately from your first game.

-
- ) : null} + {mode === "replay" ? : null} + + -
+ + +
+ + + +
+ + {pendingRoundResult ? ( + + ) : ( + <> +
+ The task shows the information needed for this round. Additional scenario details are explained later as part of the study design. +
+ +
+ {choicePreviews.map((preview) => ( + handleChoice(preview.choice)} + /> + ))} +
+ +
+ What this means: Higher care usually protects health but costs more points. Lower care saves points now but may reduce health points. +
+ + )} + + ); +} + +function ReplayNote() { + return ( +
+
+ +

+ This is an optional second playthrough. No additional survey questions will be asked. Your choices here are saved separately from your first game. +

+
+
+ ); +} + +function RoundHeader({ game, event, mode }: { game: HiddenCostGameState | ReplayGameState; event: MedicalEvent; mode: "primary" | "replay" }) { + return ( +
+
+

+ {mode === "replay" ? "Replay round" : "Round"} {event.roundNumber} of {medicalEvents.length} +

+

{event.eventName}

+

+ Choose how much care to receive while balancing financial and health points. The point values are part of a simplified simulation, not a judgment of real-life healthcare decisions. +

+
+
+ Displayed profile: {game.displayedProfile} +
+
+ ); +} + +function EventPanel({ event }: { event: MedicalEvent }) { + const icon = getEventIcon(event.eventName); + + return ( +
+
+
+
Round {event.roundNumber}
+
+ + +
+
-

- {mode === "replay" ? "Replay round" : "Round"} {currentEvent.roundNumber} of {medicalEvents.length} -

-

{currentEvent.eventName}

-

- Choose how much care to receive while balancing financial and health points. The point values are part of a simplified simulation, not a judgment of real-life healthcare decisions. +

Medical event card

+

{event.eventName}

+

+ A care decision is needed this round. Compare the point cost, the income added this round, and the health outcome before choosing.

-
-
- Displayed profile: {game.displayedProfile} +
+ Income this round: +{formatPoints(ROUND_INCOME_POINTS)} pts + Skipping risk: {event.skipRisk} +
+
+ ); +} -
- - - -
+function StatusBar({ label, value, max, icon }: { label: string; value: number; max: number; icon: string }) { + const percentage = Math.max(0, Math.min(100, (value / max) * 100)); + const fillClass = label.toLowerCase().includes("health") ? "bg-rose-500" : "bg-research-600"; -
- The task shows the information needed for this round. Additional scenario details are explained later as part of the study design. + return ( +
+
+

+ {label} +

+

{formatPoints(value)} pts

- -
- handleChoice("full-treatment")} - /> - handleChoice("partial-treatment")} - /> - handleChoice("skip-treatment")} - /> +
+
- +

Bar shows up to {formatPoints(max)} points; exact value is shown in text.

+
); } -function StatusCard({ label, value }: { label: string; value: string }) { +function ProgressStatus({ current, total }: { current: number; total: number }) { + const percentage = Math.max(0, Math.min(100, (current / total) * 100)); + return ( -
-

{label}

-

{value}

+
+
+

Round progress

+

+ Round {current}/{total} +

+
+
+
+
+

You will make {total} healthcare decisions total.

); } -function ChoiceButton({ title, cost, consequence, onClick }: { title: string; cost: number; consequence: string; onClick: () => void }) { +function ChoiceButton({ + preview, + financialBefore, + healthBefore, + disabled, + onClick, +}: { + preview: ChoicePreview; + financialBefore: number; + healthBefore: number; + disabled: boolean; + onClick: () => void; +}) { + const healthDelta = preview.healthAfter - healthBefore; + const financialDelta = preview.scoreAfter - financialBefore; + return ( ); } + +function ResultPanel({ result, mode, onContinue }: { result: RoundResult; mode: "primary" | "replay"; onContinue: () => void }) { + const continueLabel = result.isComplete ? (mode === "replay" ? "Return to results" : "Continue to visible results") : "Continue to next round"; + + return ( +
+
+
+

Round result

+

You chose: {choiceLabels[result.round.choice]}

+

Here is what happened to your points before the next screen.

+
+ +
+ +
+ + +
+ + +
+ ); +} + +function ResultChange({ label, before, after, icon }: { label: string; before: number; after: number; icon: string }) { + const delta = after - before; + + return ( +
+

+ {label} +

+

+ {formatPoints(before)} โ†’ {formatPoints(after)} pts +

+

Change from choice and round income: {formatSignedPoints(delta)}

+
+ ); +} + +function getEventIcon(eventName: string): string { + if (eventName.toLowerCase().includes("medication")) { + return "๐Ÿ’Š"; + } + + if (eventName.toLowerCase().includes("dental")) { + return "๐Ÿฆท"; + } + + if (eventName.toLowerCase().includes("diagnostic")) { + return "๐Ÿงช"; + } + + if (eventName.toLowerCase().includes("follow")) { + return "โค๏ธ"; + } + + return "๐Ÿฅ"; +} + +function formatSignedPoints(value: number): string { + if (value > 0) { + return `+${formatPoints(value)} pts`; + } + + if (value < 0) { + return `${formatPoints(value)} pts`; + } + + return "0 pts"; +}