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
12 changes: 12 additions & 0 deletions app/replay-game/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HiddenCostGame } from "@/components/HiddenCostGame";
import { PageHeader } from "@/components/PageHeader";
import { SiteShell } from "@/components/SiteShell";

export default function ReplayGamePage() {
return (
<SiteShell currentStage="individual-results">
<PageHeader title="Optional replay" description="Play one more decision-only round sequence. No background or survey questions are repeated." />
<HiddenCostGame mode="replay" />
</SiteShell>
);
}
40 changes: 40 additions & 0 deletions components/ExportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ export function ExportPanel({ session: providedSession, title = "Research JSON e
{completeness.isComplete ? "Export is complete and includes the background profile, game rounds, surveys, and computed metrics." : "Export is not complete yet. Continue the study flow to include all game rounds, surveys, and metrics."}
</div>

<ReplayPrompt session={session} />

<SubmissionPanel
status={session.serverSubmissionStatus ?? (isServerSubmissionEnabled ? "not_submitted" : "not_enabled")}
serverSubmissionId={session.serverSubmissionId}
Expand All @@ -178,6 +180,44 @@ export function ExportPanel({ session: providedSession, title = "Research JSON e
);
}

function ReplayPrompt({ session }: { session: ResearchSession }) {
const hasCompletedFirstFlow = Boolean(session.game?.completedAt && session.postRevealSurveyCompletedAt);

if (!hasCompletedFirstFlow) {
return null;
}

if (session.replayGame?.completedAt) {
return (
<div className="rounded-2xl border border-emerald-100 bg-emerald-50 p-5 text-sm leading-6 text-emerald-900">
<p className="font-semibold">Optional replay completed.</p>
<p>The JSON export and any later server submission now include replayGame and replay behavior-change metrics.</p>
</div>
);
}

return (
<div className="rounded-3xl border border-research-100 bg-white p-5 text-sm leading-6 text-slate-700 shadow-sm">
<h3 className="text-xl font-semibold text-ink">Optional second playthrough</h3>
<p className="mt-2 max-w-3xl">
You may play one more round sequence before submitting. No additional survey questions will be asked, and replay choices are saved separately from the first game.
</p>
{session.serverSubmissionStatus === "submitted" ? (
<p className="mt-3 rounded-2xl bg-amber-50 p-4 font-medium text-amber-900">
This browser session has already been submitted once. If you complete the replay and want the server copy to include it, use Submit again after returning here.
</p>
) : (
<p className="mt-3 rounded-2xl bg-research-50 p-4 font-medium text-research-900">
If you want to add replay data, please do it before optional server submission. You can also skip this and submit normally.
</p>
)}
<a href="/replay-game" className="mt-4 inline-flex rounded-full bg-research-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-research-700 focus:outline-none focus:ring-4 focus:ring-research-100">
Play one more round sequence
</a>
</div>
);
}

function normalizeSubmissionStatus(session: ResearchSession): ResearchSession {
if (!isServerSubmissionEnabled) {
return {
Expand Down
74 changes: 62 additions & 12 deletions components/HiddenCostGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,59 @@ import { Card } from "@/components/Card";
import {
ROUND_INCOME_POINTS,
createHiddenCostGameState,
createReplayGameState,
formatPoints,
getHealthAfterChoice,
getPaidCost,
getTreatmentCost,
medicalEvents,
} from "@/utils/game";
import { isPostRevealSurveyComplete } from "@/utils/researchMetrics";
import { getStoredSession, saveStoredSession } from "@/utils/session";
import type { GameChoice, HiddenCostGameState, ResearchSession } from "@/types/research";
import type { GameChoice, HiddenCostGameState, ReplayGameState, ResearchSession } from "@/types/research";

const choiceLabels: Record<GameChoice, string> = {
"full-treatment": "Full treatment",
"partial-treatment": "Partial treatment",
"skip-treatment": "Skip treatment",
};

export function HiddenCostGame() {
export function HiddenCostGame({ mode = "primary" }: { mode?: "primary" | "replay" }) {
const router = useRouter();
const [session, setSession] = useState<ResearchSession | null>(null);
const [game, setGame] = useState<HiddenCostGameState | null>(null);
const [roundStartedAt, setRoundStartedAt] = useState(() => Date.now());

useEffect(() => {
const storedSession = getStoredSession("game");
const storedSession = getStoredSession(mode === "replay" ? "individual-results" : "game");

if (mode === "replay") {
if (!storedSession.game?.completedAt || !isPostRevealSurveyComplete(storedSession.postRevealSurvey)) {
const redirectStage = storedSession.game?.completedAt ? "post-reveal" : "game";
saveStoredSession({ ...storedSession, currentStage: redirectStage });
router.replace(storedSession.game?.completedAt ? "/post-reveal-survey" : "/game");
return;
}

if (storedSession.replayGame?.completedAt) {
saveStoredSession({ ...storedSession, currentStage: "individual-results" });
router.replace("/individual-results");
return;
}

const nextReplayGame = storedSession.replayGame ?? createReplayGameState(storedSession.game);
const nextSession = {
...storedSession,
currentStage: "individual-results" as const,
replayGame: nextReplayGame,
};

saveStoredSession(nextSession);
setSession(nextSession);
setGame(nextReplayGame);
setRoundStartedAt(Date.now());
return;
}

if (!storedSession.participantProfile) {
const backgroundSession: ResearchSession = {
Expand Down Expand Up @@ -63,7 +93,7 @@ export function HiddenCostGame() {
setSession(nextSession);
setGame(nextGame);
setRoundStartedAt(Date.now());
}, [router]);
}, [mode, router]);

const currentEvent = game ? medicalEvents[game.currentRoundIndex] : undefined;
const actualFullCost = useMemo(() => {
Expand Down Expand Up @@ -114,26 +144,39 @@ export function HiddenCostGame() {
},
];
const isComplete = updatedRounds.length === medicalEvents.length;
const updatedGame: HiddenCostGameState = {
const updatedGame: HiddenCostGameState | ReplayGameState = {
...game,
financialPoints: scoreAfter,
healthPoints: healthAfter,
currentRoundIndex: isComplete ? game.currentRoundIndex : game.currentRoundIndex + 1,
completedAt: isComplete ? timestamp : undefined,
rounds: updatedRounds,
...("replayId" in game
? {
finalFinancialScore: scoreAfter,
finalHealthScore: healthAfter,
}
: {}),
};
const updatedSession: ResearchSession = {
...session,
currentStage: isComplete ? "visible-results" : "game",
game: updatedGame,
};
const updatedSession: ResearchSession =
mode === "replay"
? {
...session,
currentStage: "individual-results",
replayGame: updatedGame as ReplayGameState,
}
: {
...session,
currentStage: isComplete ? "visible-results" : "game",
game: updatedGame,
};

saveStoredSession(updatedSession);
setSession(updatedSession);
setGame(updatedGame);

if (isComplete) {
router.push("/visible-results");
router.push(mode === "replay" ? "/individual-results" : "/visible-results");
return;
}

Expand All @@ -150,10 +193,17 @@ export function HiddenCostGame() {

return (
<Card className="space-y-6">
{mode === "replay" ? (
<div className="rounded-2xl border border-research-100 bg-research-50 p-5 leading-7 text-research-900">
<p className="font-semibold">This is an optional second playthrough.</p>
<p>No additional survey questions will be asked. Your choices in this replay are saved separately from your first game.</p>
</div>
) : null}

<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-research-700">
Round {currentEvent.roundNumber} of {medicalEvents.length}
{mode === "replay" ? "Replay round" : "Round"} {currentEvent.roundNumber} of {medicalEvents.length}
</p>
<h2 className="mt-2 text-3xl font-semibold text-ink">{currentEvent.eventName}</h2>
<p className="mt-3 max-w-2xl leading-7 text-slate-600">
Expand Down
17 changes: 17 additions & 0 deletions components/ResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ function IndividualResults({
preRevealSurveyOriginal: session.preRevealSurveyOriginal,
explanationFrameCondition: session.explanationFrameCondition,
costVisibilityCondition: session.costVisibilityCondition,
replayGame: session.replayGame,
});
const interpretations = buildParticipantInterpretation(computedMetrics);

Expand Down Expand Up @@ -256,6 +257,22 @@ function MetricsGrid({ metrics }: { metrics: ComputedResearchMetrics }) {
["Cost visibility condition", metrics.costVisibilityCondition ?? "Not assigned"],
["Had any cost hint", metrics.hadAnyCostHint ? "Yes" : "No"],
["Had strong cost hint", metrics.hadStrongCostHint ? "Yes" : "No"],
["Replay available", metrics.replayAvailable ? "Yes" : "No"],
["Replay completed", metrics.replayCompleted ? "Yes" : "No"],
["Replay assignment", metrics.replayAssignmentCondition ?? "Not played"],
["Replay hidden profile", metrics.replayHiddenProfile ?? "Not played"],
["Replay full treatment choices", metrics.replayFullTreatmentChoices ?? "Not played"],
["Replay partial treatment choices", metrics.replayPartialTreatmentChoices ?? "Not played"],
["Replay skipped treatment choices", metrics.replaySkippedTreatmentChoices ?? "Not played"],
["Replay final financial score", metrics.replayFinalFinancialScore ?? "Not played"],
["Replay final health score", metrics.replayFinalHealthScore ?? "Not played"],
["Replay total treatment cost paid", metrics.replayTotalTreatmentCostPaid ?? "Not played"],
["Replay care avoidance", metrics.replayCareAvoidance ?? "Not played"],
["Behavior change: full treatment", metrics.behaviorChangeFullTreatment ?? "Not played"],
["Behavior change: partial treatment", metrics.behaviorChangePartialTreatment ?? "Not played"],
["Behavior change: skipped treatment", metrics.behaviorChangeSkippedTreatment ?? "Not played"],
["Behavior change: care avoidance", metrics.behaviorChangeCareAvoidance ?? "Not played"],
["Behavior change: cost burden", metrics.behaviorChangeCostBurden ?? "Not played"],
] as const;

return (
Expand Down
16 changes: 16 additions & 0 deletions lib/adminSubmissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,22 @@ const CSV_COLUMNS: CsvColumn[] = [
payloadColumn("skipped_treatment_choices", ["gameSummary", "skippedTreatmentChoices"]),
payloadColumn("burden", ["computedMetrics", "burden"]),
payloadColumn("care_avoidance", ["computedMetrics", "careAvoidance"]),
payloadColumn("replay_available", ["computedMetrics", "replayAvailable"]),
payloadColumn("replay_completed", ["computedMetrics", "replayCompleted"]),
payloadColumn("replay_assignment_condition", ["computedMetrics", "replayAssignmentCondition"]),
payloadColumn("replay_hidden_profile", ["computedMetrics", "replayHiddenProfile"]),
payloadColumn("replay_full_treatment_choices", ["computedMetrics", "replayFullTreatmentChoices"]),
payloadColumn("replay_partial_treatment_choices", ["computedMetrics", "replayPartialTreatmentChoices"]),
payloadColumn("replay_skipped_treatment_choices", ["computedMetrics", "replaySkippedTreatmentChoices"]),
payloadColumn("replay_final_financial_score", ["computedMetrics", "replayFinalFinancialScore"]),
payloadColumn("replay_final_health_score", ["computedMetrics", "replayFinalHealthScore"]),
payloadColumn("replay_total_treatment_cost_paid", ["computedMetrics", "replayTotalTreatmentCostPaid"]),
payloadColumn("replay_care_avoidance", ["computedMetrics", "replayCareAvoidance"]),
payloadColumn("behavior_change_full_treatment", ["computedMetrics", "behaviorChangeFullTreatment"]),
payloadColumn("behavior_change_partial_treatment", ["computedMetrics", "behaviorChangePartialTreatment"]),
payloadColumn("behavior_change_skipped_treatment", ["computedMetrics", "behaviorChangeSkippedTreatment"]),
payloadColumn("behavior_change_care_avoidance", ["computedMetrics", "behaviorChangeCareAvoidance"]),
payloadColumn("behavior_change_cost_burden", ["computedMetrics", "behaviorChangeCostBurden"]),
payloadColumn("metric_cost_visibility_condition", ["computedMetrics", "costVisibilityCondition"]),
payloadColumn("had_any_cost_hint", ["computedMetrics", "hadAnyCostHint"]),
payloadColumn("had_strong_cost_hint", ["computedMetrics", "hadStrongCostHint"]),
Expand Down
36 changes: 36 additions & 0 deletions lib/researchExportSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const revisionAccessConditionSchema = z.enum(["revision-unlocked", "revision-loc
const revealTimingConditionNameSchema = z.enum(["immediate-reveal", "delayed-reveal"]);
const explanationFrameConditionNameSchema = z.enum(["explain-to-self", "explain-to-other"]);
const costVisibilityConditionNameSchema = z.enum(["no-cost-info", "partial-cost-hint", "full-cost-preview"]);
const replayAssignmentConditionSchema = z.enum(["same-hidden-profile", "switched-hidden-profile"]);

export const revisionAccessSchema = z
.object({
Expand Down Expand Up @@ -122,6 +123,24 @@ export const gameRoundSchema = z
})
.passthrough();

export const replayGameSchema = z
.object({
replayId: z.string().min(1),
startedAt: isoDateStringSchema,
completedAt: isoDateStringSchema.optional(),
assignmentCondition: replayAssignmentConditionSchema,
displayedProfile: displayedProfileSchema,
hiddenProfile: hiddenProfileSchema,
treatmentCostMultiplier: z.number().positive(),
financialPoints: z.number(),
healthPoints: z.number(),
currentRoundIndex: z.number().int().nonnegative(),
rounds: z.array(gameRoundSchema),
finalFinancialScore: z.number(),
finalHealthScore: z.number(),
})
.passthrough();

export const preRevealSurveySchema = z
.object({
primaryAttribution: z.string().min(1),
Expand Down Expand Up @@ -177,6 +196,22 @@ export const computedMetricsSchema = z
costVisibilityCondition: costVisibilityConditionNameSchema.nullable(),
hadAnyCostHint: z.boolean(),
hadStrongCostHint: z.boolean(),
replayAvailable: z.boolean(),
replayCompleted: z.boolean(),
replayAssignmentCondition: replayAssignmentConditionSchema.optional(),
replayHiddenProfile: hiddenProfileSchema.optional(),
replayFullTreatmentChoices: z.number().int().nonnegative().optional(),
replayPartialTreatmentChoices: z.number().int().nonnegative().optional(),
replaySkippedTreatmentChoices: z.number().int().nonnegative().optional(),
replayFinalFinancialScore: z.number().optional(),
replayFinalHealthScore: z.number().optional(),
replayTotalTreatmentCostPaid: z.number().nonnegative().optional(),
replayCareAvoidance: z.number().optional(),
behaviorChangeFullTreatment: z.number().optional(),
behaviorChangePartialTreatment: z.number().optional(),
behaviorChangeSkippedTreatment: z.number().optional(),
behaviorChangeCareAvoidance: z.number().optional(),
behaviorChangeCostBurden: z.number().optional(),
attributionCategoryShift: z
.object({
pre: z.string().min(1),
Expand Down Expand Up @@ -228,6 +263,7 @@ export const researchExportSchema = z
assignedProfile: assignedProfileSchema,
gameSummary: gameSummarySchema,
gameRounds: z.array(gameRoundSchema).min(1),
replayGame: replayGameSchema.optional(),
preRevealSurvey: preRevealSurveySchema,
preRevealSurveyOriginal: preRevealSurveySchema.optional(),
preRevealSurveyRevisedAfterReveal: preRevealSurveySchema.optional(),
Expand Down
9 changes: 7 additions & 2 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-4",
"schemaVersion": "hidden-cost-game-research-schema-6",
"sessionId": "hcg-example-session",
"consentVersion": "pilot-consent-v1",
"serverSubmissionStatus": "not_submitted",
Expand Down Expand Up @@ -182,7 +182,12 @@
"pre": "individual choices",
"post": "structural constraints"
},
"explanationFrame": "explain-to-self"
"explanationFrame": "explain-to-self",
"costVisibilityCondition": null,
"hadAnyCostHint": false,
"hadStrongCostHint": false,
"replayAvailable": true,
"replayCompleted": false
},
"completeness": {
"hasParticipantProfile": true,
Expand Down
Loading