diff --git a/frontend/src/components/experiment_builder/experiment_builder.ts b/frontend/src/components/experiment_builder/experiment_builder.ts
index b5abe9918..cbb482df7 100644
--- a/frontend/src/components/experiment_builder/experiment_builder.ts
+++ b/frontend/src/components/experiment_builder/experiment_builder.ts
@@ -12,6 +12,7 @@ import '../stages/info_editor';
import '../stages/payout_editor';
import '../stages/profile_stage_editor';
import '../stages/reveal_editor';
+import '../stages/role_editor';
import '../stages/stockinfo_editor';
import '../stages/survey_editor';
import '../stages/survey_per_participant_editor';
@@ -462,6 +463,11 @@ export class ExperimentBuilder extends MobxLitElement {
`;
+ case StageKind.ROLE:
+ return html`
+
+
+ `;
case StageKind.STOCKINFO:
return html`
diff --git a/frontend/src/components/experiment_builder/stage_builder_dialog.ts b/frontend/src/components/experiment_builder/stage_builder_dialog.ts
index 2204831fc..be8253daf 100644
--- a/frontend/src/components/experiment_builder/stage_builder_dialog.ts
+++ b/frontend/src/components/experiment_builder/stage_builder_dialog.ts
@@ -24,6 +24,7 @@ import {
createPrivateChatStage,
createProfileStage,
createRevealStage,
+ createRoleStage,
createStockInfoStage,
createSurveyPerParticipantStage,
createSurveyStage,
@@ -175,8 +176,8 @@ export class StageBuilderDialog extends MobxLitElement {
${this.renderTransferCard()} ${this.renderSurveyCard()}
${this.renderSurveyPerParticipantCard()} ${this.renderFlipCardCard()}
${this.renderRankingCard()} ${this.renderRevealCard()}
- ${this.renderPayoutCard()} ${this.renderStockInfoCard()}
- ${this.renderAssetAllocationCard()}
+ ${this.renderPayoutCard()} ${this.renderRoleCard()}
+ ${this.renderStockInfoCard()} ${this.renderAssetAllocationCard()}
`;
@@ -315,6 +316,22 @@ export class StageBuilderDialog extends MobxLitElement {
`;
}
+ private renderRoleCard() {
+ const addStage = () => {
+ this.addStage(createRoleStage());
+ };
+
+ return html`
+
+
Role assignment
+
+ Randomly assign roles to participants and show different
+ Markdown-rendered info for each role
+
+
+ `;
+ }
+
private renderGroupChatCard() {
const addStage = () => {
this.addStage(createChatStage());
diff --git a/frontend/src/components/participant_view/participant_view.ts b/frontend/src/components/participant_view/participant_view.ts
index bae955d74..bf6883a2e 100644
--- a/frontend/src/components/participant_view/participant_view.ts
+++ b/frontend/src/components/participant_view/participant_view.ts
@@ -13,6 +13,7 @@ import '../stages/payout_participant_view';
import '../stages/profile_participant_editor';
import '../stages/profile_participant_view';
import '../stages/reveal_participant_view';
+import '../stages/role_participant_view';
import '../stages/salesperson_participant_view';
import '../stages/asset_allocation_participant_view';
import '../stages/stockinfo_participant_view';
@@ -257,6 +258,10 @@ export class ParticipantView extends MobxLitElement {
return html`
`;
+ case StageKind.ROLE:
+ return html`
+
+ `;
case StageKind.SALESPERSON:
return html`
diff --git a/frontend/src/components/stages/role_editor.scss b/frontend/src/components/stages/role_editor.scss
new file mode 100644
index 000000000..7d472e798
--- /dev/null
+++ b/frontend/src/components/stages/role_editor.scss
@@ -0,0 +1,30 @@
+@use '../../sass/colors';
+@use '../../sass/common';
+@use '../../sass/typescale';
+
+:host {
+ @include common.flex-column;
+ gap: common.$spacing-xxl;
+}
+
+.role {
+ @include common.flex-column;
+ background: var(--md-sys-color-surface-variant);
+ gap: common.$spacing-large;
+ padding: common.$spacing-large;
+}
+
+.subtitle {
+ @include typescale.label-small;
+ color: var(--md-sys-color-outlline);
+}
+
+.checkbox-wrapper {
+ @include common.flex-row-align-center;
+ gap: common.$spacing-small;
+ overflow-wrap: break-word;
+
+ md-checkbox {
+ flex-shrink: 0;
+ }
+}
diff --git a/frontend/src/components/stages/role_editor.ts b/frontend/src/components/stages/role_editor.ts
new file mode 100644
index 000000000..8edd71013
--- /dev/null
+++ b/frontend/src/components/stages/role_editor.ts
@@ -0,0 +1,158 @@
+import {MobxLitElement} from '@adobe/lit-mobx';
+import {CSSResultGroup, html, nothing} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+import '../../pair-components/button';
+import '@material/web/textfield/outlined-text-field.js';
+
+import {core} from '../../core/core';
+import {ExperimentEditor} from '../../services/experiment.editor';
+
+import {
+ RoleItem,
+ RoleStageConfig,
+ StageKind,
+ createRoleItem,
+} from '@deliberation-lab/utils';
+
+import {styles} from './role_editor.scss';
+
+/** Editor for role stage. */
+@customElement('role-editor')
+export class RoleEditorComponent extends MobxLitElement {
+ static override styles: CSSResultGroup = [styles];
+
+ private readonly experimentEditor = core.getService(ExperimentEditor);
+
+ @property() stage: RoleStageConfig | undefined = undefined;
+
+ override render() {
+ if (this.stage === undefined) {
+ return nothing;
+ }
+
+ return html`
+ ${this.stage.roles.map((role, index) => this.renderRoleItem(role, index))}
+ ${this.renderAddRoleButton()}
+ `;
+ }
+
+ private renderAddRoleButton() {
+ const addButton = () => {
+ if (!this.stage) return;
+
+ const roles = [...this.stage.roles, createRoleItem()];
+ this.experimentEditor.updateStage({...this.stage, roles});
+ };
+
+ return html` Add new role `;
+ }
+
+ private renderRoleItem(role: RoleItem, index: number) {
+ const updateRoleItem = (config: Partial) => {
+ if (!this.stage) return;
+
+ const roles = [
+ ...this.stage.roles.slice(0, index),
+ {...role, ...config},
+ ...this.stage.roles.slice(index + 1),
+ ];
+ this.experimentEditor.updateStage({...this.stage, roles});
+ };
+
+ const updateName = (e: InputEvent) => {
+ const name = (e.target as HTMLTextAreaElement).value;
+ updateRoleItem({name});
+ };
+
+ const updateDisplayLines = (e: InputEvent) => {
+ const value = (e.target as HTMLTextAreaElement).value;
+ updateRoleItem({displayLines: [value]});
+ };
+
+ const updateMinParticipants = (e: InputEvent) => {
+ const minParticipants = Number((e.target as HTMLTextAreaElement).value);
+ updateRoleItem({minParticipants});
+ };
+
+ const updateMaxParticipantsNumber = (e: InputEvent) => {
+ const maxParticipants = Number((e.target as HTMLTextAreaElement).value);
+ updateRoleItem({maxParticipants});
+ };
+
+ const toggleMaxParticipants = () => {
+ if (role.maxParticipants === null) {
+ updateRoleItem({maxParticipants: 100});
+ } else {
+ updateRoleItem({maxParticipants: null});
+ }
+ };
+
+ return html`
+
+
Role ID: ${role.id}
+
+
+
+
+
+
+
+
Set maximum number of participants assigned to this role
+
+
+
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'role-editor': RoleEditorComponent;
+ }
+}
diff --git a/frontend/src/components/stages/role_participant_view.ts b/frontend/src/components/stages/role_participant_view.ts
new file mode 100644
index 000000000..c83a085b6
--- /dev/null
+++ b/frontend/src/components/stages/role_participant_view.ts
@@ -0,0 +1,83 @@
+import '../progress/progress_stage_completed';
+
+import './stage_description';
+import './stage_footer';
+
+import {MobxLitElement} from '@adobe/lit-mobx';
+import {CSSResultGroup, html, nothing} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+import {core} from '../../core/core';
+import {CohortService} from '../../services/cohort.service';
+import {ParticipantService} from '../../services/participant.service';
+
+import {RoleStageConfig, StageKind} from '@deliberation-lab/utils';
+
+import {unsafeHTML} from 'lit/directives/unsafe-html.js';
+import {convertMarkdownToHTML} from '../../shared/utils';
+import {styles} from './info_view.scss';
+
+/** Role stage view for participants. */
+@customElement('role-participant-view')
+export class RoleView extends MobxLitElement {
+ static override styles: CSSResultGroup = [styles];
+
+ private readonly cohortService = core.getService(CohortService);
+ private readonly participantService = core.getService(ParticipantService);
+
+ @property() stage: RoleStageConfig | null = null;
+
+ override render() {
+ if (!this.stage) {
+ return nothing;
+ }
+
+ const publicData = this.cohortService.stagePublicDataMap[this.stage.id];
+ if (publicData?.kind !== StageKind.ROLE) {
+ return nothing;
+ }
+
+ const roleId =
+ publicData.participantMap[
+ this.participantService.profile?.publicId ?? ''
+ ];
+ const role = this.stage.roles.find((role) => role.id === roleId);
+
+ const getRole = () => {
+ this.participantService.setParticipantRoles(this.stage?.id ?? '');
+ };
+
+ const renderRoleDisplay = () => {
+ if (!role) return nothing;
+ return html`
+ ${unsafeHTML(convertMarkdownToHTML(role.displayLines.join('\n\n')))}
+ `;
+ };
+
+ const renderRoleButton = () => {
+ return html`
+ Get my participant role
+ `;
+ };
+
+ return html`
+
+
+
+ ${role ? renderRoleDisplay() : renderRoleButton()}
+
+
+
+ ${this.stage.progress.showParticipantProgress
+ ? html``
+ : nothing}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'role-view': RoleView;
+ }
+}
diff --git a/frontend/src/services/participant.service.ts b/frontend/src/services/participant.service.ts
index 24ef6b34a..44a09bce4 100644
--- a/frontend/src/services/participant.service.ts
+++ b/frontend/src/services/participant.service.ts
@@ -9,6 +9,7 @@ import {
ParticipantProfileBase,
ParticipantProfileExtended,
ParticipantStatus,
+ RoleStageConfig,
StageKind,
StageParticipantAnswer,
SurveyAnswer,
@@ -52,6 +53,7 @@ import {
sendChipOfferCallable,
sendChipResponseCallable,
setChipTurnCallable,
+ setParticipantRolesCallable,
setSalespersonControllerCallable,
setSalespersonMoveCallable,
setSalespersonResponseCallable,
@@ -979,6 +981,21 @@ export class ParticipantService extends Service {
return output;
}
+ async setParticipantRoles(stageId: string) {
+ let output = {success: false};
+ if (this.experimentId && this.profile) {
+ output = await setParticipantRolesCallable(
+ this.sp.firebaseService.functions,
+ {
+ experimentId: this.experimentId,
+ cohortId: this.profile.currentCohortId,
+ stageId,
+ },
+ );
+ }
+ return output;
+ }
+
async sendAlertMessage(message: string) {
let response = {};
if (this.experimentId && this.profile) {
diff --git a/frontend/src/shared/callables.ts b/frontend/src/shared/callables.ts
index 0d7b46ff7..314be0257 100644
--- a/frontend/src/shared/callables.ts
+++ b/frontend/src/shared/callables.ts
@@ -21,6 +21,7 @@ import {
SendChipResponseData,
SendParticipantCheckData,
SetChipTurnData,
+ SetParticipantRolesData,
SetSalespersonControllerData,
SetSalespersonMoveData,
SetSalespersonResponseData,
@@ -410,6 +411,18 @@ export const createChatMessageCallable = async (
return data;
};
+/** Generic endpoint for assigning participants to roles for role stage. */
+export const setParticipantRolesCallable = async (
+ functions: Functions,
+ config: SetParticipantRolesData,
+) => {
+ const {data} = await httpsCallable(
+ functions,
+ 'setParticipantRoles',
+ )(config);
+ return data;
+};
+
/** Generic endpoint for sending chip negotiation offer. */
export const sendChipOfferCallable = async (
functions: Functions,
diff --git a/functions/src/agent_participant.utils.ts b/functions/src/agent_participant.utils.ts
index a0a3c58c5..ee5e2e292 100644
--- a/functions/src/agent_participant.utils.ts
+++ b/functions/src/agent_participant.utils.ts
@@ -16,6 +16,7 @@ import {
import {createAgentChatMessageFromPrompt} from './chat/chat.agent';
import {completeProfile} from './stages/profile.utils';
import {getAgentParticipantRankingStageResponse} from './stages/ranking.agent';
+import {assignRolesToParticipants} from './stages/role.utils';
import {getAgentParticipantSurveyStageResponse} from './stages/survey.agent';
import {
getExperimenterData,
@@ -100,6 +101,15 @@ export async function completeStageAsAgentParticipant(
await completeStage();
participantDoc.set(participant);
break;
+ case StageKind.ROLE:
+ await assignRolesToParticipants(
+ experimentId,
+ participant.currentCohortId,
+ stage.id,
+ );
+ await completeStage();
+ participantDoc.set(participant);
+ break;
case StageKind.SALESPERSON:
createAgentChatMessageFromPrompt(
experimentId,
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 8ff5da53a..cacb8e2cf 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -17,6 +17,7 @@ export * from './stages/chip.endpoints';
export * from './stages/flipcard.endpoints';
export * from './stages/ranking.endpoints';
+export * from './stages/role.endpoints';
export * from './stages/salesperson.endpoints';
export * from './stages/survey.endpoints';
diff --git a/functions/src/participant.endpoints.ts b/functions/src/participant.endpoints.ts
index 80378153e..d44bcd2c4 100644
--- a/functions/src/participant.endpoints.ts
+++ b/functions/src/participant.endpoints.ts
@@ -6,6 +6,7 @@ import {
Experiment,
ParticipantProfileExtended,
ParticipantStatus,
+ RoleStagePublicData,
StageKind,
SurveyStagePublicData,
createParticipantProfileExtended,
@@ -531,6 +532,13 @@ export const acceptParticipantTransfer = onCall(async (request) => {
publicChipData.participantChipValueMap[publicId] = stage.chipValueMap;
transaction.set(publicDocument, publicChipData);
break;
+ case StageKind.ROLE:
+ const publicRoleData = (
+ await publicDocument.get()
+ ).data() as RoleStagePublicData;
+ // TODO: Assign new role to participant (or move role over)
+ transaction.set(publicDocument, publicRoleData);
+ break;
default:
break;
}
diff --git a/functions/src/prompt.utils.ts b/functions/src/prompt.utils.ts
index 88aaa0412..40991a8b0 100644
--- a/functions/src/prompt.utils.ts
+++ b/functions/src/prompt.utils.ts
@@ -18,7 +18,9 @@ import {
import {
getFirestoreAnswersForStage,
getFirestoreExperiment,
+ getFirestoreParticipant,
getFirestoreStage,
+ getFirestoreStagePublicData,
getFirestorePublicStageChatMessages,
getFirestorePrivateChatMessages,
} from './utils/firestore';
@@ -176,6 +178,29 @@ export async function getStageDisplayForPrompt(
stage.id,
);
return getChatPromptMessageHistory(privateMessages, stage);
+ case StageKind.ROLE:
+ const rolePublicData = await getFirestoreStagePublicData(
+ experimentId,
+ cohortId,
+ stage.id,
+ );
+ const getRoleDisplay = (roleId: string) => {
+ if (stage.kind !== StageKind.ROLE) return '';
+ return (
+ stage.roles.find((role) => role.id === roleId)?.displayLines ?? []
+ );
+ };
+ const roleInfo: string[] = [];
+ for (const participantId of participantIds) {
+ const participant = await getFirestoreParticipant(
+ experimentId,
+ participantId,
+ );
+ roleInfo.push(
+ `${participant.publicId}: ${getRoleDisplay(rolePublicData.participantMap[participant.publicId] ?? '').join('\n\n')}`,
+ );
+ }
+ return roleInfo.join('\n');
case StageKind.STOCKINFO:
return getStockInfoSummaryText(stage);
case StageKind.ASSET_ALLOCATION:
diff --git a/functions/src/stages/role.endpoints.ts b/functions/src/stages/role.endpoints.ts
new file mode 100644
index 000000000..bf1149d21
--- /dev/null
+++ b/functions/src/stages/role.endpoints.ts
@@ -0,0 +1,39 @@
+import {
+ RoleItem,
+ RoleStageConfig,
+ RoleStagePublicData,
+ StageKind,
+} from '@deliberation-lab/utils';
+
+import {
+ getFirestoreActiveParticipants,
+ getFirestoreStage,
+ getFirestoreStagePublicDataRef,
+} from '../utils/firestore';
+import {assignRolesToParticipants} from './role.utils';
+
+import * as admin from 'firebase-admin';
+import * as functions from 'firebase-functions';
+import {onCall} from 'firebase-functions/v2/https';
+
+import {app} from '../app';
+
+// ************************************************************************* //
+// setParticipantRoles endpoint //
+// //
+// Randomly assign one of the stage's defined roles to each of the active //
+// cohort participants. //
+// //
+// Input structure: { //
+// experimentId, cohortId, stageId //
+// } //
+// Validation: utils/src/role_stage.validation.ts //
+// ************************************************************************* //
+export const setParticipantRoles = onCall(async (request) => {
+ const {data} = request;
+ const experimentId = data.experimentId;
+ const cohortId = data.cohortId;
+ const stageId = data.stageId;
+
+ return await assignRolesToParticipants(experimentId, cohortId, stageId);
+});
diff --git a/functions/src/stages/role.utils.ts b/functions/src/stages/role.utils.ts
new file mode 100644
index 000000000..ac03a4780
--- /dev/null
+++ b/functions/src/stages/role.utils.ts
@@ -0,0 +1,92 @@
+import {
+ RoleItem,
+ RoleStageConfig,
+ RoleStagePublicData,
+ StageKind,
+} from '@deliberation-lab/utils';
+
+import {
+ getFirestoreActiveParticipants,
+ getFirestoreStage,
+ getFirestoreStagePublicDataRef,
+} from '../utils/firestore';
+
+import * as admin from 'firebase-admin';
+import * as functions from 'firebase-functions';
+
+import {app} from '../app';
+/** Assign roles to participants for given stage. */
+export async function assignRolesToParticipants(
+ experimentId: string,
+ cohortId: string,
+ stageId: string,
+) {
+ // Define role stage config
+ const stage = await getFirestoreStage(experimentId, stageId);
+ if (stage.kind !== StageKind.ROLE) {
+ return {success: false};
+ }
+
+ // Define role stage public data document reference
+ const publicDoc = getFirestoreStagePublicDataRef(
+ experimentId,
+ cohortId,
+ stageId,
+ );
+
+ await app.firestore().runTransaction(async (transaction) => {
+ const publicStageData = (
+ await publicDoc.get()
+ ).data() as RoleStagePublicData;
+
+ // Get relevant (active, in cohort) participants
+ const participants = await getFirestoreActiveParticipants(
+ experimentId,
+ cohortId,
+ stageId,
+ );
+
+ // TODO: For each participant, check if they have been assigned a role
+ // If not, assign role according to stage minimum/maximums
+ const getRoleCounts = () => {
+ const roleToFrequencyMap: Record = {};
+ Object.values(publicStageData.participantMap).forEach((role) => {
+ roleToFrequencyMap[role] = (roleToFrequencyMap[role] ?? 0) + 1;
+ });
+ return roleToFrequencyMap;
+ };
+ const getNextRole = () => {
+ const roleToFrequencyMap = getRoleCounts();
+ // First, fill roles with minimum number of participants required
+ for (const role of stage.roles) {
+ const roleFrequency = roleToFrequencyMap[role.id] ?? 0;
+ if (
+ roleFrequency < role.minParticipants &&
+ roleFrequency < role.maxParticipants
+ ) {
+ return role;
+ }
+ }
+ // Otherwise, randomly pick role
+ const availableRoles = stage.roles.filter(
+ (role) =>
+ (roleToFrequencyMap[role.id] ?? 0) < role.maxParticipants ||
+ role.maxParticipants === null,
+ );
+ return availableRoles[Math.floor(Math.random() * availableRoles.length)];
+
+ return null;
+ };
+
+ for (const participant of participants) {
+ if (!publicStageData.participantMap[participant.publicId]) {
+ publicStageData.participantMap[participant.publicId] =
+ getNextRole()?.id ?? '';
+ }
+ }
+
+ transaction.set(publicDoc, publicStageData);
+ }); // end transaction
+
+ return {success: true};
+}
diff --git a/utils/src/index.ts b/utils/src/index.ts
index 167687baa..3cfebf57a 100644
--- a/utils/src/index.ts
+++ b/utils/src/index.ts
@@ -65,6 +65,8 @@ export * from './stages/flipcard_stage.validation';
export * from './stages/ranking_stage';
export * from './stages/ranking_stage.prompts';
export * from './stages/ranking_stage.validation';
+export * from './stages/role_stage';
+export * from './stages/role_stage.validation';
export * from './stages/info_stage';
export * from './stages/info_stage.prompts';
export * from './stages/info_stage.validation';
diff --git a/utils/src/stages/role_stage.ts b/utils/src/stages/role_stage.ts
new file mode 100644
index 000000000..d922f72c1
--- /dev/null
+++ b/utils/src/stages/role_stage.ts
@@ -0,0 +1,79 @@
+import {generateId} from '../shared';
+import {
+ BaseStageConfig,
+ BaseStagePublicData,
+ StageKind,
+ createStageTextConfig,
+ createStageProgressConfig,
+} from './stage';
+
+/** Role assignment stage types and functions. */
+
+// ************************************************************************* //
+// TYPES //
+// ************************************************************************* //
+
+export interface RoleStageConfig extends BaseStageConfig {
+ kind: StageKind.ROLE;
+ roles: RoleItem[];
+}
+
+export interface RoleItem {
+ id: string; // unique identifier
+ name: string; // name of role
+ displayLines: string[]; // markdown content to display to user
+ minParticipants: number;
+ maxParticipants: number | null; // if null, no limit on participants
+}
+
+/**
+ * RoleStagePublicData.
+ *
+ * This is saved as a stage doc (with stage ID as doc ID) under
+ * experiments/{experimentId}/cohorts/{cohortId}/publicStageData
+ */
+export interface RoleStagePublicData extends BaseStagePublicData {
+ kind: StageKind.ROLE;
+ // Maps from participant public ID to role ID
+ participantMap: Record;
+}
+
+// ************************************************************************* //
+// FUNCTIONS //
+// ************************************************************************* //
+
+/** Create role stage. */
+export function createRoleStage(
+ config: Partial = {},
+): RoleStageConfig {
+ return {
+ id: config.id ?? generateId(),
+ kind: StageKind.ROLE,
+ name: config.name ?? 'Role assignment',
+ descriptions: config.descriptions ?? createStageTextConfig(),
+ progress: config.progress ?? createStageProgressConfig(),
+ roles: config.roles ?? [],
+ };
+}
+
+/** Create role item. */
+export function createRoleItem(config: Partial = {}): RoleItem {
+ return {
+ id: config.id ?? generateId(),
+ name: config.name ?? '',
+ displayLines: config.displayLines ?? [],
+ minParticipants: config.minParticipants ?? 0,
+ maxParticipants: config.maxParticipants ?? null,
+ };
+}
+
+/** Create role stage public data. */
+export function createRoleStagePublicData(
+ config: RoleStageConfig,
+): RoleStagePublicData {
+ return {
+ id: config.id,
+ kind: StageKind.ROLE,
+ participantMap: {},
+ };
+}
diff --git a/utils/src/stages/role_stage.validation.ts b/utils/src/stages/role_stage.validation.ts
new file mode 100644
index 000000000..3e0d22996
--- /dev/null
+++ b/utils/src/stages/role_stage.validation.ts
@@ -0,0 +1,50 @@
+import {Type, type Static} from '@sinclair/typebox';
+import {StageKind} from './stage';
+import {
+ StageProgressConfigSchema,
+ StageTextConfigSchema,
+} from './stage.validation';
+
+/** Shorthand for strict TypeBox object validation */
+const strict = {additionalProperties: false} as const;
+
+// ************************************************************************* //
+// writeExperiment, updateStageConfig endpoints //
+// ************************************************************************* //
+
+/** RoleItem data validation. */
+export const RoleItemData = Type.Object(
+ {
+ id: Type.String({minLength: 1}),
+ name: Type.String(),
+ displayLines: Type.Array(Type.String()),
+ minParticipants: Type.Number(),
+ maxParticipants: Type.Union([Type.Number(), Type.Null()]),
+ },
+ strict,
+);
+
+/** RoleStageConfig input validation. */
+export const RoleStageConfigData = Type.Object(
+ {
+ id: Type.String({minLength: 1}),
+ kind: Type.Literal(StageKind.ROLE),
+ name: Type.String({minLength: 1}),
+ descriptions: StageTextConfigSchema,
+ progress: StageProgressConfigSchema,
+ roles: Type.Array(RoleItemData),
+ },
+ strict,
+);
+
+/** setParticipantRoles endpoint data validation. */
+export const SetParticipantRolesData = Type.Object(
+ {
+ experimentId: Type.String({minLength: 1}),
+ cohortId: Type.String({minLength: 1}),
+ stageId: Type.String({minLength: 1}),
+ },
+ strict,
+);
+
+export type SetParticipantRolesData = Static;
diff --git a/utils/src/stages/stage.ts b/utils/src/stages/stage.ts
index 242618ca6..7350d40a8 100644
--- a/utils/src/stages/stage.ts
+++ b/utils/src/stages/stage.ts
@@ -31,6 +31,11 @@ import {PayoutStageConfig, PayoutStageParticipantAnswer} from './payout_stage';
import {PrivateChatStageConfig} from './private_chat_stage';
import {ProfileStageConfig} from './profile_stage';
import {RevealStageConfig} from './reveal_stage';
+import {
+ RoleStageConfig,
+ RoleStagePublicData,
+ createRoleStagePublicData,
+} from './role_stage';
import {
SalespersonStageConfig,
SalespersonStagePublicData,
@@ -80,6 +85,7 @@ export enum StageKind {
SALESPERSON = 'salesperson', // co-op traveling salesperson game
STOCKINFO = 'stockinfo',
ASSET_ALLOCATION = 'assetAllocation', // asset allocation between stocks
+ ROLE = 'role', // info stage that assigns different roles to participants
SURVEY = 'survey',
SURVEY_PER_PARTICIPANT = 'surveyPerParticipant',
TRANSFER = 'transfer',
@@ -125,6 +131,7 @@ export type StageConfig =
| SalespersonStageConfig
| StockInfoStageConfig
| AssetAllocationStageConfig
+ | RoleStageConfig
| SurveyStageConfig
| SurveyPerParticipantStageConfig
| TOSStageConfig
@@ -172,6 +179,7 @@ export type StagePublicData =
| ChipStagePublicData
| FlipCardStagePublicData
| RankingStagePublicData
+ | RoleStagePublicData
| SalespersonStagePublicData
| AssetAllocationStagePublicData
| SurveyStagePublicData;
@@ -224,6 +232,9 @@ export function createPublicDataFromStageConfigs(stages: StageConfig[]) {
case StageKind.RANKING:
publicData.push(createRankingStagePublicData(stage.id));
break;
+ case StageKind.ROLE:
+ publicData.push(createRoleStagePublicData(stage));
+ break;
case StageKind.SALESPERSON:
publicData.push(
createSalespersonStagePublicData(stage.id, stage.board.startCoord),
diff --git a/utils/src/stages/stage.validation.ts b/utils/src/stages/stage.validation.ts
index a081b6c76..5c4f7d47f 100644
--- a/utils/src/stages/stage.validation.ts
+++ b/utils/src/stages/stage.validation.ts
@@ -10,6 +10,7 @@ import {PayoutStageConfigData} from './payout_stage.validation';
import {PrivateChatStageConfigData} from './private_chat_stage.validation';
import {ProfileStageConfigData} from './profile_stage.validation';
import {RevealStageConfigData} from './reveal_stage.validation';
+import {RoleStageConfigData} from './role_stage.validation';
import {SalespersonStageConfigData} from './salesperson_stage.validation';
import {StockInfoStageConfigData} from './stockinfo_stage.validation';
import {
@@ -36,6 +37,7 @@ export const StageConfigData = Type.Union([
ProfileStageConfigData,
RankingStageConfigData,
RevealStageConfigData,
+ RoleStageConfigData,
SalespersonStageConfigData,
StockInfoStageConfigData,
SurveyPerParticipantStageConfigData,