From 4637d0db7ec6b767f05a1126f0d4fbea74017a05 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Tue, 12 May 2026 05:55:57 +0000 Subject: [PATCH] Link round income to health --- components/HiddenCostGame.tsx | 44 +++++++++++---- components/ResultsTable.tsx | 24 ++++++++- lib/adminSubmissions.ts | 12 +++++ lib/researchExportSchema.ts | 7 +++ .../complete-research-export.example.json | 53 +++++++++++++------ types/research.ts | 7 +++ utils/game.ts | 24 +++++++++ utils/researchMetrics.ts | 27 ++++++++-- 8 files changed, 164 insertions(+), 34 deletions(-) diff --git a/components/HiddenCostGame.tsx b/components/HiddenCostGame.tsx index affd1dc..c199e22 100644 --- a/components/HiddenCostGame.tsx +++ b/components/HiddenCostGame.tsx @@ -9,7 +9,9 @@ import { createReplayGameState, formatPoints, getHealthAfterChoice, + getHealthIncomeMultiplier, getPaidCost, + getRoundIncomeForHealth, getTreatmentCost, medicalEvents, } from "@/utils/game"; @@ -43,6 +45,9 @@ type RoundResult = { type ChoicePreview = { choice: GameChoice; paidCost: number; + roundIncome: number; + baseRoundIncome: number; + healthIncomeMultiplier: number; scoreAfter: number; healthAfter: number; }; @@ -144,11 +149,15 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla return (["full-treatment", "partial-treatment", "skip-treatment"] as GameChoice[]).map((choice) => { const paidCost = getPaidCost(choice, actualFullCost, actualPartialCost); + const roundIncome = getRoundIncomeForHealth(game.healthPoints); return { choice, paidCost, - scoreAfter: Number((game.financialPoints + ROUND_INCOME_POINTS - paidCost).toFixed(2)), + roundIncome, + baseRoundIncome: ROUND_INCOME_POINTS, + healthIncomeMultiplier: getHealthIncomeMultiplier(game.healthPoints), + scoreAfter: Number((game.financialPoints + roundIncome - paidCost).toFixed(2)), healthAfter: getHealthAfterChoice(choice, game.healthPoints), }; }); @@ -163,7 +172,11 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla const paidCost = getPaidCost(choice, actualFullCost, actualPartialCost); const scoreBefore = game.financialPoints; const healthBefore = game.healthPoints; - const scoreAfter = Number((scoreBefore + ROUND_INCOME_POINTS - paidCost).toFixed(2)); + // Use health at the start of the round because current health affects earning capacity during that round. + const roundIncome = getRoundIncomeForHealth(healthBefore); + const baseRoundIncome = ROUND_INCOME_POINTS; + const healthIncomeMultiplier = getHealthIncomeMultiplier(healthBefore); + const scoreAfter = Number((scoreBefore + roundIncome - paidCost).toFixed(2)); const healthAfter = getHealthAfterChoice(choice, healthBefore); const roundData: GameRoundData = { roundNumber: currentEvent.roundNumber, @@ -176,6 +189,9 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla actualPartialCost, choice, paidCost, + roundIncome, + baseRoundIncome, + healthIncomeMultiplier, scoreBefore, scoreAfter, healthBefore, @@ -250,7 +266,7 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla - +
@@ -280,7 +296,7 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla
- What this means: Higher care usually protects health but costs more points. Lower care saves points now but may reduce health points. + What this means: Higher care usually protects health but costs more points. Lower care saves points now but may reduce health points. Lower health can reduce the points earned in later rounds.
)} @@ -322,8 +338,9 @@ function RoundHeader({ game, event, mode }: { game: HiddenCostGameState | Replay ); } -function EventPanel({ event }: { event: MedicalEvent }) { +function EventPanel({ event, healthPoints }: { event: MedicalEvent; healthPoints: number }) { const icon = getEventIcon(event.eventName); + const healthAdjustedIncome = getRoundIncomeForHealth(healthPoints); return (
- Income this round: +{formatPoints(ROUND_INCOME_POINTS)} pts + Base round income: +{formatPoints(ROUND_INCOME_POINTS)} pts + Health-adjusted income: +{formatPoints(healthAdjustedIncome)} pts Skipping risk: {event.skipRisk}
@@ -434,23 +452,27 @@ function ChoiceButton({ - Cost + Treatment cost -{formatPoints(preview.paidCost)} pts - Income this round - +{formatPoints(ROUND_INCOME_POINTS)} pts + Base round income + +{formatPoints(preview.baseRoundIncome)} pts + + + Health-adjusted income + +{formatPoints(preview.roundIncome)} pts After this choice - Financial points + Estimated financial after choice {formatPoints(preview.scoreAfter)} pts ({formatSignedPoints(financialDelta)}) - Health points + Estimated health after choice {formatPoints(preview.healthAfter)} pts ({formatSignedPoints(healthDelta)}) diff --git a/components/ResultsTable.tsx b/components/ResultsTable.tsx index a39155a..c152be7 100644 --- a/components/ResultsTable.tsx +++ b/components/ResultsTable.tsx @@ -96,12 +96,28 @@ export function ResultsTable({ mode = "visible" }: { mode?: ResultsMode }) {
- Continue to questions + {session ? : null}
); } +function VisibleResultsContinueButton({ session }: { session: ResearchSession }) { + if (isPostRevealSurveyComplete(session.postRevealSurvey)) { + return Review results and export data; + } + + if (session.revealViewedAt) { + return Continue to follow-up; + } + + if (isPreRevealSurveyComplete(session.preRevealSurvey)) { + return Continue to debrief; + } + + return Continue to interpretation questions; +} + function CostVisibilityNote({ condition }: { condition: CostVisibilityConditionName }) { if (condition === "no-cost-info") { return null; @@ -193,7 +209,10 @@ function GameSummarySection({ summary }: { summary: GameSummary }) { - + + + + @@ -248,6 +267,7 @@ function MetricsGrid({ metrics }: { metrics: ComputedResearchMetrics }) { ["Perspective Change", metrics.perspectiveChange], ["Cost burden ratio", metrics.burden], ["Care avoidance index", metrics.careAvoidance], + ["Health-adjusted income loss", metrics.healthAdjustedIncomeLoss ?? 0], ["Attribution category shift", `${metrics.attributionCategoryShift.pre} → ${metrics.attributionCategoryShift.post}`], ["Remembered responsibility error", metrics.rememberedResponsibilityError], ["Remembered constraint suspicion error", metrics.rememberedConstraintSuspicionError], diff --git a/lib/adminSubmissions.ts b/lib/adminSubmissions.ts index e086b87..5ca4d28 100644 --- a/lib/adminSubmissions.ts +++ b/lib/adminSubmissions.ts @@ -579,6 +579,18 @@ const CSV_COLUMNS: CsvColumn[] = [ "totalTreatmentCostPaid", ]), payloadColumn("total_income", ["gameSummary", "totalIncome"]), + payloadColumn("total_actual_round_income", [ + "gameSummary", + "totalActualRoundIncome", + ]), + payloadColumn("theoretical_base_income", [ + "gameSummary", + "theoreticalBaseIncome", + ]), + payloadColumn("health_adjusted_income_loss", [ + "gameSummary", + "healthAdjustedIncomeLoss", + ]), payloadColumn("full_treatment_choices", [ "gameSummary", "fullTreatmentChoices", diff --git a/lib/researchExportSchema.ts b/lib/researchExportSchema.ts index dcf1d6b..10d6e7a 100644 --- a/lib/researchExportSchema.ts +++ b/lib/researchExportSchema.ts @@ -95,6 +95,9 @@ export const gameSummarySchema = z finalHealthScore: z.number(), totalTreatmentCostPaid: z.number().nonnegative(), totalIncome: z.number().nonnegative(), + totalActualRoundIncome: z.number().nonnegative().optional(), + theoreticalBaseIncome: z.number().nonnegative().optional(), + healthAdjustedIncomeLoss: z.number().nonnegative().optional(), fullTreatmentChoices: z.number().int().nonnegative(), partialTreatmentChoices: z.number().int().nonnegative(), skippedTreatmentChoices: z.number().int().nonnegative(), @@ -113,6 +116,9 @@ export const gameRoundSchema = z actualPartialCost: z.number().nonnegative(), choice: gameChoiceSchema, paidCost: z.number().nonnegative(), + roundIncome: z.number().nonnegative().optional(), + baseRoundIncome: z.number().nonnegative().optional(), + healthIncomeMultiplier: z.number().min(0).max(1).optional(), scoreBefore: z.number(), scoreAfter: z.number(), healthBefore: z.number(), @@ -185,6 +191,7 @@ export const computedMetricsSchema = z perspectiveChange: z.number(), burden: z.number(), careAvoidance: z.number(), + healthAdjustedIncomeLoss: z.number().nonnegative().optional(), delayedReveal: z.boolean(), standByInitialInterpretation: likertSchema.optional(), rememberedResponsibilityError: z.number(), diff --git a/sample-data/complete-research-export.example.json b/sample-data/complete-research-export.example.json index 77c5445..3d1b0df 100644 --- a/sample-data/complete-research-export.example.json +++ b/sample-data/complete-research-export.example.json @@ -1,6 +1,6 @@ { "exportVersion": "prototype-1.2", - "schemaVersion": "hidden-cost-game-research-schema-6", + "schemaVersion": "hidden-cost-game-research-schema-7", "sessionId": "hcg-example-session", "consentVersion": "pilot-consent-v1", "serverSubmissionStatus": "not_submitted", @@ -35,13 +35,16 @@ "gameSummary": { "actualHiddenProfile": "Low coverage", "assignedProfile": "Profile B", - "finalFinancialScore": 45, - "finalHealthScore": 90, + "finalFinancialScore": 83.0, + "finalHealthScore": 65, "fullTreatmentChoices": 3, "partialTreatmentChoices": 1, "skippedTreatmentChoices": 1, "totalTreatmentCostPaid": 155, - "totalIncome": 200 + "totalIncome": 193.0, + "totalActualRoundIncome": 93.0, + "theoreticalBaseIncome": 200, + "healthAdjustedIncomeLoss": 7.0 }, "gameRounds": [ { @@ -55,12 +58,15 @@ "actualPartialCost": 10, "choice": "full-treatment", "paidCost": 20, - "scoreBefore": 120, + "scoreBefore": 100, "scoreAfter": 100, "healthBefore": 100, "healthAfter": 100, "timestamp": "2026-05-08T00:02:00.000Z", - "decisionTimeMs": 4200 + "decisionTimeMs": 4200, + "baseRoundIncome": 20, + "healthIncomeMultiplier": 1, + "roundIncome": 20 }, { "roundNumber": 2, @@ -73,12 +79,15 @@ "actualPartialCost": 15, "choice": "partial-treatment", "paidCost": 15, - "scoreBefore": 120, + "scoreBefore": 100, "scoreAfter": 105, "healthBefore": 100, "healthAfter": 90, "timestamp": "2026-05-08T00:04:00.000Z", - "decisionTimeMs": 5100 + "decisionTimeMs": 5100, + "baseRoundIncome": 20, + "healthIncomeMultiplier": 1, + "roundIncome": 20 }, { "roundNumber": 3, @@ -91,12 +100,15 @@ "actualPartialCost": 20, "choice": "full-treatment", "paidCost": 40, - "scoreBefore": 125, + "scoreBefore": 105, "scoreAfter": 85, "healthBefore": 90, "healthAfter": 90, "timestamp": "2026-05-08T00:06:00.000Z", - "decisionTimeMs": 3900 + "decisionTimeMs": 3900, + "baseRoundIncome": 20, + "healthIncomeMultiplier": 1, + "roundIncome": 20 }, { "roundNumber": 4, @@ -109,12 +121,15 @@ "actualPartialCost": 25, "choice": "skip-treatment", "paidCost": 0, - "scoreBefore": 105, + "scoreBefore": 85, "scoreAfter": 105, "healthBefore": 90, "healthAfter": 65, "timestamp": "2026-05-08T00:08:00.000Z", - "decisionTimeMs": 6100 + "decisionTimeMs": 6100, + "baseRoundIncome": 20, + "healthIncomeMultiplier": 1, + "roundIncome": 20 }, { "roundNumber": 5, @@ -127,12 +142,15 @@ "actualPartialCost": 15, "choice": "full-treatment", "paidCost": 35, - "scoreBefore": 125, - "scoreAfter": 90, + "scoreBefore": 105, + "scoreAfter": 83.0, "healthBefore": 65, "healthAfter": 65, "timestamp": "2026-05-08T00:09:00.000Z", - "decisionTimeMs": 4700 + "decisionTimeMs": 4700, + "baseRoundIncome": 20, + "healthIncomeMultiplier": 0.65, + "roundIncome": 13.0 } ], "preRevealSurvey": { @@ -170,7 +188,7 @@ "certaintyCorrection": 2, "informationCaution": 4, "perspectiveChange": 6, - "burden": 0.775, + "burden": 0.8031, "careAvoidance": 1.5, "delayedReveal": false, "rememberedResponsibilityError": -1, @@ -187,7 +205,8 @@ "hadAnyCostHint": false, "hadStrongCostHint": false, "replayAvailable": true, - "replayCompleted": false + "replayCompleted": false, + "healthAdjustedIncomeLoss": 7.0 }, "completeness": { "hasParticipantProfile": true, diff --git a/types/research.ts b/types/research.ts index b7ace0e..4ede6f6 100644 --- a/types/research.ts +++ b/types/research.ts @@ -70,6 +70,9 @@ export interface GameRoundData { actualPartialCost: number; choice: GameChoice; paidCost: number; + roundIncome?: number; + baseRoundIncome?: number; + healthIncomeMultiplier?: number; scoreBefore: number; scoreAfter: number; healthBefore: number; @@ -138,6 +141,9 @@ export interface GameSummary extends TreatmentChoiceCounts { finalHealthScore: number; totalTreatmentCostPaid: number; totalIncome: number; + totalActualRoundIncome: number; + theoreticalBaseIncome: number; + healthAdjustedIncomeLoss: number; } export interface AttributionCategoryShift { @@ -197,6 +203,7 @@ export interface ComputedResearchMetrics { perspectiveChange: number; burden: number; careAvoidance: number; + healthAdjustedIncomeLoss?: number; attributionCategoryShift: AttributionCategoryShift; usedRevisionOpportunity?: boolean; revisionUnlocked?: boolean | null; diff --git a/utils/game.ts b/utils/game.ts index 0aabf8a..c14da6e 100644 --- a/utils/game.ts +++ b/utils/game.ts @@ -76,6 +76,30 @@ export function getTreatmentCost(baseCost: number, multiplier: number): number { return Number((baseCost * multiplier).toFixed(2)); } +export function getHealthIncomeMultiplier(healthPoints: number): number { + if (healthPoints >= 90) { + return 1; + } + + if (healthPoints >= 75) { + return 0.85; + } + + if (healthPoints >= 50) { + return 0.65; + } + + if (healthPoints >= 25) { + return 0.4; + } + + return 0.2; +} + +export function getRoundIncomeForHealth(healthPoints: number): number { + return Number((ROUND_INCOME_POINTS * getHealthIncomeMultiplier(healthPoints)).toFixed(2)); +} + export function getPaidCost(choice: GameChoice, fullCost: number, partialCost: number): number { if (choice === "full-treatment") { return fullCost; diff --git a/utils/researchMetrics.ts b/utils/researchMetrics.ts index f180634..cc49cb6 100644 --- a/utils/researchMetrics.ts +++ b/utils/researchMetrics.ts @@ -13,7 +13,7 @@ import type { } from "@/types/research"; export const RESEARCH_EXPORT_VERSION = "prototype-1.2"; -export const RESEARCH_SCHEMA_VERSION = "hidden-cost-game-research-schema-6"; +export const RESEARCH_SCHEMA_VERSION = "hidden-cost-game-research-schema-7"; export const RESEARCH_CONSENT_VERSION = "pilot-consent-v1"; const choiceCountKeys: Record = { @@ -78,12 +78,27 @@ export function calculateTotalTreatmentCostPaid(game: HiddenCostGameState): numb return roundMetric(game.rounds.reduce((total, round) => total + round.paidCost, 0)); } -export function calculateTotalIncome(numberOfRounds: number): number { - return STARTING_FINANCIAL_POINTS + ROUND_INCOME_POINTS * numberOfRounds; +export function calculateActualRoundIncomeTotal(game: HiddenCostGameState): number { + return roundMetric(game.rounds.reduce((total, round) => total + (round.roundIncome ?? ROUND_INCOME_POINTS), 0)); +} + +export function calculateTotalIncome(game: HiddenCostGameState): number { + return roundMetric(STARTING_FINANCIAL_POINTS + calculateActualRoundIncomeTotal(game)); +} + +export function calculateTheoreticalBaseIncome(numberOfRounds: number): number { + return roundMetric(STARTING_FINANCIAL_POINTS + ROUND_INCOME_POINTS * numberOfRounds); +} + +export function calculateHealthAdjustedIncomeLoss(game: HiddenCostGameState): number { + return roundMetric(calculateTheoreticalBaseIncome(game.rounds.length) - calculateTotalIncome(game)); } export function calculateGameSummary(game: HiddenCostGameState): GameSummary { const choiceCounts = calculateTreatmentChoiceCounts(game); + const totalActualRoundIncome = calculateActualRoundIncomeTotal(game); + const totalIncome = calculateTotalIncome(game); + const theoreticalBaseIncome = calculateTheoreticalBaseIncome(game.rounds.length); return { actualHiddenProfile: game.hiddenProfile, @@ -92,7 +107,10 @@ export function calculateGameSummary(game: HiddenCostGameState): GameSummary { finalHealthScore: game.healthPoints, ...choiceCounts, totalTreatmentCostPaid: calculateTotalTreatmentCostPaid(game), - totalIncome: calculateTotalIncome(game.rounds.length), + totalIncome, + totalActualRoundIncome, + theoreticalBaseIncome, + healthAdjustedIncomeLoss: roundMetric(theoreticalBaseIncome - totalIncome), }; } @@ -163,6 +181,7 @@ export function calculateComputedResearchMetrics({ perspectiveChange: postRevealSurvey.perspectiveChange, burden: originalCostBurden, careAvoidance: summary.skippedTreatmentChoices + 0.5 * summary.partialTreatmentChoices, + healthAdjustedIncomeLoss: summary.healthAdjustedIncomeLoss, delayedReveal: revealTimingCondition?.condition === "delayed-reveal", ...(preRevealCommitment ? { standByInitialInterpretation: preRevealCommitment.standByInitialInterpretation } : {}), rememberedResponsibilityError,