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,