Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions components/HiddenCostGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
createReplayGameState,
formatPoints,
getHealthAfterChoice,
getHealthIncomeMultiplier,
getPaidCost,
getRoundIncomeForHealth,
getTreatmentCost,
medicalEvents,
} from "@/utils/game";
Expand Down Expand Up @@ -43,6 +45,9 @@ type RoundResult = {
type ChoicePreview = {
choice: GameChoice;
paidCost: number;
roundIncome: number;
baseRoundIncome: number;
healthIncomeMultiplier: number;
scoreAfter: number;
healthAfter: number;
};
Expand Down Expand Up @@ -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),
};
});
Expand All @@ -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,
Expand All @@ -176,6 +189,9 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla
actualPartialCost,
choice,
paidCost,
roundIncome,
baseRoundIncome,
healthIncomeMultiplier,
scoreBefore,
scoreAfter,
healthBefore,
Expand Down Expand Up @@ -250,7 +266,7 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla

<RoundHeader game={game} event={displayEvent} mode={mode} />

<EventPanel event={displayEvent} />
<EventPanel event={displayEvent} healthPoints={pendingRoundResult ? pendingRoundResult.round.healthBefore : game.healthPoints} />

<div className="grid gap-4 lg:grid-cols-[1fr_1fr_0.8fr]">
<StatusBar label="Financial points" value={displayFinancialPoints} max={100} icon="💰" />
Expand Down Expand Up @@ -280,7 +296,7 @@ export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "repla
</div>

<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm font-semibold leading-6 text-amber-950">
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.
</div>
</>
)}
Expand Down Expand Up @@ -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 (
<section
Expand All @@ -350,7 +367,8 @@ function EventPanel({ event }: { event: MedicalEvent }) {
A care decision is needed this round. Compare the point cost, the income added this round, and the health outcome before choosing.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm font-semibold">
<span className="rounded-full bg-white px-4 py-2 text-slate-700 shadow-sm">Income this round: +{formatPoints(ROUND_INCOME_POINTS)} pts</span>
<span className="rounded-full bg-white px-4 py-2 text-slate-700 shadow-sm">Base round income: +{formatPoints(ROUND_INCOME_POINTS)} pts</span>
<span className="rounded-full bg-white px-4 py-2 text-slate-700 shadow-sm">Health-adjusted income: +{formatPoints(healthAdjustedIncome)} pts</span>
<span className="rounded-full bg-white px-4 py-2 capitalize text-slate-700 shadow-sm">Skipping risk: {event.skipRisk}</span>
</div>
</div>
Expand Down Expand Up @@ -434,23 +452,27 @@ function ChoiceButton({

<span className="mt-5 grid gap-3 text-sm text-slate-700">
<span className="flex justify-between gap-3 rounded-2xl bg-slate-50 px-4 py-3">
<span className="font-medium">Cost</span>
<span className="font-medium">Treatment cost</span>
<span className="font-bold text-rose-700">-{formatPoints(preview.paidCost)} pts</span>
</span>
<span className="flex justify-between gap-3 rounded-2xl bg-emerald-50 px-4 py-3">
<span className="font-medium">Income this round</span>
<span className="font-bold text-emerald-800">+{formatPoints(ROUND_INCOME_POINTS)} pts</span>
<span className="font-medium">Base round income</span>
<span className="font-bold text-emerald-800">+{formatPoints(preview.baseRoundIncome)} pts</span>
</span>
<span className="flex justify-between gap-3 rounded-2xl bg-emerald-50 px-4 py-3">
<span className="font-medium">Health-adjusted income</span>
<span className="font-bold text-emerald-800">+{formatPoints(preview.roundIncome)} pts</span>
</span>
<span className="rounded-2xl border border-research-100 bg-research-50 px-4 py-3">
<span className="block font-semibold text-research-900">After this choice</span>
<span className="mt-2 flex justify-between gap-3">
<span>Financial points</span>
<span>Estimated financial after choice</span>
<span className="font-bold text-research-900">
{formatPoints(preview.scoreAfter)} pts ({formatSignedPoints(financialDelta)})
</span>
</span>
<span className="mt-1 flex justify-between gap-3">
<span>Health points</span>
<span>Estimated health after choice</span>
<span className="font-bold text-research-900">
{formatPoints(preview.healthAfter)} pts ({formatSignedPoints(healthDelta)})
</span>
Expand Down
24 changes: 22 additions & 2 deletions components/ResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,28 @@ export function ResultsTable({ mode = "visible" }: { mode?: ResultsMode }) {
<ResultsRankingTable players={sortedPlayers} />

<div className="flex justify-end border-t border-slate-200 pt-6">
<ButtonLink href="/pre-reveal-survey">Continue to questions</ButtonLink>
{session ? <VisibleResultsContinueButton session={session} /> : null}
</div>
</Card>
);
}

function VisibleResultsContinueButton({ session }: { session: ResearchSession }) {
if (isPostRevealSurveyComplete(session.postRevealSurvey)) {
return <ButtonLink href="/individual-results">Review results and export data</ButtonLink>;
}

if (session.revealViewedAt) {
return <ButtonLink href="/post-reveal-survey">Continue to follow-up</ButtonLink>;
}

if (isPreRevealSurveyComplete(session.preRevealSurvey)) {
return <ButtonLink href="/hidden-rule-reveal">Continue to debrief</ButtonLink>;
}

return <ButtonLink href="/pre-reveal-survey">Continue to interpretation questions</ButtonLink>;
}

function CostVisibilityNote({ condition }: { condition: CostVisibilityConditionName }) {
if (condition === "no-cost-info") {
return null;
Expand Down Expand Up @@ -193,7 +209,10 @@ function GameSummarySection({ summary }: { summary: GameSummary }) {
<ResultCard label="Partial treatment choices" value={summary.partialTreatmentChoices.toString()} />
<ResultCard label="Skipped treatment choices" value={summary.skippedTreatmentChoices.toString()} />
<ResultCard label="Total treatment cost paid" value={`${formatPoints(summary.totalTreatmentCostPaid)} pts`} />
<ResultCard label="Total available income" value={`${formatPoints(summary.totalIncome)} pts`} />
<ResultCard label="Actual available income" value={`${formatPoints(summary.totalIncome)} pts`} />
<ResultCard label="Round income earned" value={`${formatPoints(summary.totalActualRoundIncome)} pts`} />
<ResultCard label="Base income possible" value={`${formatPoints(summary.theoreticalBaseIncome)} pts`} />
<ResultCard label="Income reduced by health" value={`${formatPoints(summary.healthAdjustedIncomeLoss)} pts`} />
<ResultCard label="Assigned profile label" value={summary.assignedProfile} />
</div>
</div>
Expand Down Expand Up @@ -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],
Expand Down
12 changes: 12 additions & 0 deletions lib/adminSubmissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions lib/researchExportSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
53 changes: 36 additions & 17 deletions sample-data/complete-research-export.example.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": [
{
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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": {
Expand Down Expand Up @@ -170,7 +188,7 @@
"certaintyCorrection": 2,
"informationCaution": 4,
"perspectiveChange": 6,
"burden": 0.775,
"burden": 0.8031,
"careAvoidance": 1.5,
"delayedReveal": false,
"rememberedResponsibilityError": -1,
Expand All @@ -187,7 +205,8 @@
"hadAnyCostHint": false,
"hadStrongCostHint": false,
"replayAvailable": true,
"replayCompleted": false
"replayCompleted": false,
"healthAdjustedIncomeLoss": 7.0
},
"completeness": {
"hasParticipantProfile": true,
Expand Down
7 changes: 7 additions & 0 deletions types/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -138,6 +141,9 @@ export interface GameSummary extends TreatmentChoiceCounts {
finalHealthScore: number;
totalTreatmentCostPaid: number;
totalIncome: number;
totalActualRoundIncome: number;
theoreticalBaseIncome: number;
healthAdjustedIncomeLoss: number;
}

export interface AttributionCategoryShift {
Expand Down Expand Up @@ -197,6 +203,7 @@ export interface ComputedResearchMetrics {
perspectiveChange: number;
burden: number;
careAvoidance: number;
healthAdjustedIncomeLoss?: number;
attributionCategoryShift: AttributionCategoryShift;
usedRevisionOpportunity?: boolean;
revisionUnlocked?: boolean | null;
Expand Down
Loading