diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..f5ee2b15863301c945a2ef9684fb4690402f070b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.log +_db.sqlite3 +/build +/tmp +node_modules +.idea +.vscode +.DS_Store +db.sqlite3 +policy_guess_anchoring.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5c874936bb3cf2f2436d4dc0248cda9a1ec5d566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +venv +staticfiles +./db.sqlite3 +.idea +*~ +*.sqlite3 +_static_root +_bots*s +__temp* +__pycache__/ +*.py[cod] +.DS_Store +merge.ps1 +*.otreezip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4c58b92fab0e1ba10a782780a5d37da58e445767 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && pip install --no-cache-dir -r requirements.txt \ + && apt-get purge -y build-essential \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY . /app + +ENV OTREE_PRODUCTION=1 \ + OTREE_ADMIN_PASSWORD=changeme \ + OTREE_DATABASE_URL=sqlite:////data/otree.sqlite3 \ + PYTHONPATH=/app + +RUN mkdir -p /data \ + && otree collectstatic --noinput + +COPY docker-entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +EXPOSE 8000 + +CMD ["/app/entrypoint.sh"] diff --git a/EXPORTS.md b/EXPORTS.md new file mode 100644 index 0000000000000000000000000000000000000000..c5c9703ef16c53f4928fd020ec72a3a7009607f4 --- /dev/null +++ b/EXPORTS.md @@ -0,0 +1,40 @@ +# Exports Guide (ibe-pp) + +This note explains the key fields you’ll see in CSV/Excel exports from the demo sessions and how to interpret them in class. + +## Common fields +- session_code, participant_code: Internal identifiers for linking records. +- app_name, round_number, page_name: Where a row came from. +- payoff: Participant payoff in the app (points or currency units). + +## Classic/Policy Games +- Prisoner’s Dilemma: `cooperate` (True/False), `payoff`. +- Trust (simple/framed): `sent_amount`, `sent_back_amount`, `payoff`. +- Dictator (norms): `kept` (amount P1 kept), `payoff`. +- Public Goods (base/defaults): `contribution`, group totals (if included), `payoff`. + +Interpretation tips +- Trust: reciprocity shows in `sent_back_amount` relative to `sent_amount`×multiplier. +- Public Goods: highlight total vs. private trade-off driving `payoff`. +- Dictator: distribution between P1 and P2 indicates fairness preferences; note framing. + +## Survey Biases (policy_survey_biases) +- Overconfidence (90% CI): `oc1_low`, `oc1_high`, `oc2_low`, `oc2_high`, `oc3_low`, `oc3_high`. + - In class: discuss calibration by checking how often intervals would contain the true value (avoid revealing “correct” answers live). +- Overplacement: `self_rank_policy_knowledge` (1–7). + - In class: look at distribution vs. mid-point 4. +- Confirmation: `agree_statement_before`, `agree_statement_after` (1–7). + - In class: look at whether responses moved after the snippet; both change and no-change are informative. +- Availability: `recall_event` (free text), `priority_energy`, `priority_public_health` (1–7). + - In class: discuss how salient events might shift priorities. + +## Exporting with bots (optional) +- Use Make targets to run bots and then export from the admin. For quick bot runs: + - `make otree-test-classic` + - `make otree-test-policy` + - `make otree-test-anchoring` + - `make otree-test-survey` + +## Notes +- Avoid showing “correct” answers for overconfidence during class; focus on calibration concepts. +- Frame findings as illustrations; behavior varies by group, context, and incentives. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..b2ed33ff82a86eb9c81e9a5fe67ab6707d54aa0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2014 Daniel Li Chen, Martin Walter Schonger, Christopher Wickens. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +The Licensee undertakes to mention the name oTree, the names of the licensors +(Daniel L. Chen, Martin Schonger and Christopher Wickens) and to cite the +following article in all publications in which results of experiments conducted +with the Software are published: Chen, Daniel L., Martin Schonger, and Chris Wickens. +2016. "oTree - An open-source platform for laboratory, online, and field experiments." +Journal of Behavioral and Experimental Finance, vol 9: 88-97. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000000000000000000000000000000000000..11e1b7d5f296b77b72ba07f311c81eb647a80889 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: otree prodserver1of2 +worker: otree prodserver2of2 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..26f5ef657b3f6d4f5b10f4825c859292b829fba6 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# oTree HF Production Template + +This directory packages the IBE public policy oTree demos for deployment on a Hugging Face Space using the Docker runtime. It was cloned from `otree-projects/ibe-pp` and augmented with container and deployment assets. + +## Directory Overview + +- `Dockerfile` – builds the production image expected by Hugging Face Spaces. +- `docker-entrypoint.sh` – runtime bootstrap that ensures SQLite initialization, collects static files, and launches the ASGI server. +- `requirements.txt` – locked dependencies for the production image. +- `.dockerignore` – keeps dev artifacts (`db.sqlite3`, logs, caches) out of the build context. +- `settings.py`, apps/, tests/ – unchanged oTree project source. + +## Deploying to a Hugging Face Space + +1. **Create the Space** + - Runtime: `Docker`. + - Visibility: choose `Private` or `Public` per your needs. +2. **Add secrets / environment variables** (Settings → Repository secrets): + + | Key | Purpose | Example | + | --- | --- | --- | + | `OTREE_ADMIN_PASSWORD` | Protects the oTree admin interface | `set-a-unique-password` | + | `OTREE_AUTH_LEVEL` | Optional access control (`STUDY`, `DEMO`, `BASELINE`) | `STUDY` | + | `OTREE_DATABASE_URL` | Override DB (defaults to SQLite under `/data`) | `postgres://user:pass@host:5432/dbname` | + + Additional variables such as `SENTRY_DSN`, `OTREE_SECRET_KEY`, or mail settings can also be defined here. + +3. **Populate the repository** + ```bash + # from repository root + cd otree-projects/hf-prod-ibe-pp + git init # if pushing standalone + git add . + git commit -m "Initial oTree HF deployment" + git remote add origin https://huggingface.co/spaces// + git push origin main + ``` + +4. **Space build & launch** + - Hugging Face automatically builds the Docker image. + - The container starts `/app/entrypoint.sh`, which: + - Creates the SQLite database at `/data/otree.sqlite3` if `OTREE_DATABASE_URL` points to SQLite. + - Runs `otree collectstatic --noinput` so static assets are up to date. + - Launches `otree prodserver1of2 0.0.0.0:$PORT` (HF sets `$PORT`). + - The app becomes available at `https://-.hf.space/`. + +## Local Verification + +To build and run the same image locally before pushing: + +```bash +cd otree-projects/hf-prod-ibe-pp +DOCKER_BUILDKIT=1 docker build -t ibe-pp-hf . +docker run --rm -p 8000:8000 \ + -e OTREE_ADMIN_PASSWORD=changeme \ + -e OTREE_AUTH_LEVEL=STUDY \ + ibe-pp-hf +# open http://localhost:8000/ +``` + +For persistence, mount a volume: +```bash +docker run --rm -p 8000:8000 \ + -v $(pwd)/data:/data \ + -e OTREE_ADMIN_PASSWORD=changeme \ + ibe-pp-hf +``` + +## Notes & Customization + +- **Database**: The default `OTREE_DATABASE_URL=sqlite:////data/otree.sqlite3` provides demo-ready storage but resets whenever the container restarts on Hugging Face. Point to an external Postgres instance for durable experiments. +- **Static assets**: Adjust `SKIP_COLLECTSTATIC=1` in the Space secrets to skip the runtime collect step if you pre-build assets during CI. +- **Session configs**: Update `settings.py` or add new apps as usual; rebuild the Space to apply changes. +- **Logging / monitoring**: Set `SENTRY_DSN`, `OTREE_PRODUCTION=1` (already set), or add custom logging handlers as desired. + +Once pushed, you can administer the server via `https:///otree/admin` using the password stored in `OTREE_ADMIN_PASSWORD`. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000000000000000000000000000000000000..04e53bc8a04f40fc53b7ac7cd71c8007caf427d7 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,72 @@ +# Testing Guide (ibe-pp) + +This project ships with a complete test suite covering cloned apps and full-session flows. Tests never modify oTree core and run against the local project only. + +## Test Types +- Unit-lite: config and import sanity checks (no DB writes). +- App bots (oTree): app-level flows in each cloned app (`tests.py` inside the app). +- E2E sessions (pytest): spins up full sessions via `otree test ` using fresh SQLite DBs. +- Matrix/infra checks: participant-count matrix, rooms/labels, static assets. + +## Layout +- `policy_*/tests.py` – Bot specs per app: + - `policy_public_goods_defaults`, `policy_trust_framed`, `policy_dictator_norms`, `policy_guess_anchoring`, `policy_survey_biases` + - Patterns used: `expect()` to assert payoffs and `SubmissionMustFail(...)` to test validation failures (see `policy_survey_biases`). +- `payment_info/tests.py` – No-op bot to satisfy multi-app sessions. +- `tests/test_integration_settings.py` – Verifies session configs and app folders. +- `tests/test_e2e_bots.py` – Runs E2E bots for configured sessions (`policy_nudges`, `anchoring_demo`, `survey_biases_full`). + +Survey Biases (policy_survey_biases) modules and tests +- Module A (Information and Views): randomized order `order_a ∈ {info_before, info_after}` with a comprehension check. Bots cover both branches and include a negative case via `SubmissionMustFail` for a wrong comprehension answer. +- Module B (Statements and Evidence): randomized order `order_b ∈ {support_first, eval_first}`. Bots cover both branches. +- Module C (Topics and News): randomized order `order_c ∈ {recall_first, opinion_first}` with validation requiring ~20 characters for each recall text. Bots assert negative cases (short input) then submit valid responses. +- Results page safely handles optional fields using `field_maybe_none(...)` to avoid null access during rendering. + +Running just the survey biases suite +- `make otree-test-survey` (fresh SQLite DB) or: + - `source scripts/activate_venv.sh && cd otree-projects/ibe-pp && OTREE_DATABASE_URL=sqlite:///tmp_survey.sqlite3 otree test survey_biases_full` +- `tests/test_session_matrix_and_rooms.py` – Participant-count matrix (valid/invalid), rooms label file presence, and static file presence. +- `tests/test_static_http.py` – Optional HTTP check for `/_static/custom.css` when `RUN_OTREE_HTTP_TESTS=1`. + +## How To Run +- Quick run (recommended): + - `make test-ibe-pp` (pytest over `tests/`) + - `make test-otree-e2e` (serial bot runs across key sessions) +- Manual (from project root): + - `source scripts/activate_venv.sh` + - `cd otree-projects/ibe-pp && pytest -q tests` +- Single E2E: + - `cd otree-projects/ibe-pp && pytest -q tests/test_e2e_bots.py::test_bots_policy_nudges_sequence` +- Optional HTTP static check: + - `RUN_OTREE_HTTP_TESTS=1 cd otree-projects/ibe-pp && pytest -q tests/test_static_http.py` + +## Dependencies +- Added to root `requirements.txt`: `pytest`, `requests`, `httpx`, `starlette==0.14.1`. +- Pin rationale: oTree 5.11.4 bots expect Starlette 0.14.x; newer Starlette breaks bot TestClient. +- If running FastAPI tooling simultaneously, consider a dedicated test requirements file or separate venv. + +## E2E Strategy +- Tests set `OTREE_DATABASE_URL=sqlite:///test_db_.sqlite3` to isolate DBs. +- If you run `otree test` manually and see “delete your database”, remove `db.sqlite3` or set `OTREE_DATABASE_URL`. +- Participant-count matrix asserts success/failure for group-size compatibility. + +## Adding Tests +- New app: add `tests.py` alongside `__init__.py` with a `PlayerBot(Bot)` and yields for each page. +- Use `expect()` to assert key state (e.g., payoffs), and `SubmissionMustFail(...)` for invalid input branches. +- New session: add a test in `tests/test_e2e_bots.py` invoking `run_otree_test('')`. + +## Common Issues +- Missing dependencies for bots: install `requests` and `httpx` (already in `requirements.txt`). +- Name collisions: cloned apps set unique `NAME_IN_URL` values; keep this when adding apps. +- Timeouts: `prisoner.Introduction` now uses `get_timeout_seconds`; override with `session.config['prisoner_intro_timeout']` in `settings.py` if needed, for example: + + ```python + SESSION_CONFIGS = [ + dict( + name='classic_baseline', + app_sequence=['sequence_welcome', 'prisoner', 'trust_simple', 'public_goods_simple', 'payment_info'], + num_demo_participants=6, + prisoner_intro_timeout=30, # seconds (optional) + ), + ] + ``` diff --git a/_rooms/econ101.txt b/_rooms/econ101.txt new file mode 100644 index 0000000000000000000000000000000000000000..2a7a778bb6a23f691a29bcf82724854935283d0a --- /dev/null +++ b/_rooms/econ101.txt @@ -0,0 +1,3 @@ +Alice +Bob +Charlie \ No newline at end of file diff --git a/_rooms/lab_demo.txt b/_rooms/lab_demo.txt new file mode 100644 index 0000000000000000000000000000000000000000..fd05e1a6f3fdb343955a2870167e30802d568afb --- /dev/null +++ b/_rooms/lab_demo.txt @@ -0,0 +1,10 @@ +# Optional participant labels for lab demo (one per line). +# You can leave this file empty to allow unlabeled participants. +alpha +bravo +charlie +delta +echo +foxtrot +golf +hotel diff --git a/_static/custom.css b/_static/custom.css new file mode 100644 index 0000000000000000000000000000000000000000..6ab012f9ca736e78a86aa1836fb9efda798b9753 --- /dev/null +++ b/_static/custom.css @@ -0,0 +1,47 @@ +/* Accessibility & readability tweaks for oTree pages */ + +html, body { + font-size: 18px; /* larger base for readability */ + line-height: 1.5; +} + +.container, .otree-body { + max-width: 900px; +} + +/* Buttons */ +.btn, button, input[type=submit] { + padding: 0.6rem 1rem; + font-size: 1rem; + border-radius: 6px; +} + +.btn-primary { + background-color: #0a58ca; /* higher contrast */ + border-color: #0a58ca; +} + +.btn-primary:hover, .btn-primary:focus { + background-color: #084298; + border-color: #084298; +} + +/* Inputs */ +input[type=number], input[type=text], textarea, select { + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Focus visibility */ +a:focus, button:focus, .btn:focus, input:focus, select:focus, textarea:focus { + outline: 3px solid #ffbf47; /* distinct focus ring */ + outline-offset: 2px; +} + +/* Cards spacing */ +.card.bg-light.m-3 .card-body { font-size: 0.98rem; } + +/* Lists in instructions */ +ul { margin-top: 0.25rem; } +li { margin-bottom: 0.25rem; } + diff --git a/_static/global/empty.css b/_static/global/empty.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/_templates/global/Page.html b/_templates/global/Page.html new file mode 100644 index 0000000000000000000000000000000000000000..c4c59d38988f317adc1f66f0883378fb9cdfe8fa --- /dev/null +++ b/_templates/global/Page.html @@ -0,0 +1,8 @@ +{{ extends "otree/Page.html" }} +{{ load otree static }} + +{{ block global_styles }} + +{{ endblock }} +{{ block global_scripts }} +{{ endblock }} diff --git a/bargaining/Introduction.html b/bargaining/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..9d7b251fdbb167084c2a10f572ebb528c742672b --- /dev/null +++ b/bargaining/Introduction.html @@ -0,0 +1,7 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ {{ include_sibling 'instructions.html' }} + {{ next_button }} +{{ endblock }} diff --git a/bargaining/Request.html b/bargaining/Request.html new file mode 100644 index 0000000000000000000000000000000000000000..cf03c39a07263345003bc7537ec0d6427387198a --- /dev/null +++ b/bargaining/Request.html @@ -0,0 +1,12 @@ +{{ block title }}Request{{ endblock }} +{{ block content }} + +

How much will you demand for yourself?

+ + {{ formfields }} + +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/bargaining/Results.html b/bargaining/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..87cb8aa42d3dacde8ea98b4d011bc5a74b9583c5 --- /dev/null +++ b/bargaining/Results.html @@ -0,0 +1,26 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + + + + + + + + + + + + + + + + + +
You demanded{{ player.request }}
The other participant demanded{{ other_player_request }}
Sum of your demands{{ group.total_requests }}
Thus you earn{{ player.payoff }}
+ +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/bargaining/__init__.py b/bargaining/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..194c5f9c44272ef547e8617340ccdc079491ea71 --- /dev/null +++ b/bargaining/__init__.py @@ -0,0 +1,75 @@ +from otree.api import * + + + + +doc = """ +This bargaining game involves 2 players. Each demands for a portion of some +available amount. If the sum of demands is no larger than the available +amount, both players get demanded portions. Otherwise, both get nothing. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'bargaining' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + AMOUNT_SHARED = cu(100) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + total_requests = models.CurrencyField() + + +class Player(BasePlayer): + request = models.CurrencyField( + doc=""" + Amount requested by this player. + """, + min=0, + max=C.AMOUNT_SHARED, + label="Please enter an amount from 0 to 100", + ) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + group.total_requests = sum([p.request for p in players]) + if group.total_requests <= C.AMOUNT_SHARED: + for p in players: + p.payoff = p.request + else: + for p in players: + p.payoff = cu(0) + + +def other_player(player: Player): + return player.get_others_in_group()[0] + + +# PAGES +class Introduction(Page): + pass + + +class Request(Page): + form_model = 'player' + form_fields = ['request'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + return dict(other_player_request=other_player(player).request) + + +page_sequence = [Introduction, Request, ResultsWaitPage, Results] diff --git a/bargaining/instructions.html b/bargaining/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..e062231282fc152717d2a2690b42ef1e03a7bdfc --- /dev/null +++ b/bargaining/instructions.html @@ -0,0 +1,20 @@ + +
+ +
+

+ Instructions +

+ +

+ You have been randomly and anonymously paired with another participant. + There is {{ C.AMOUNT_SHARED }} for you to divide. + Both of you have to simultaneously and independently demand a portion + of the {{ C.AMOUNT_SHARED }} for yourselves. If the sum of your + demands is smaller or equal to {{ C.AMOUNT_SHARED }}, both of + you get what you demanded. If the sum of your demands is larger + than {{ C.AMOUNT_SHARED }}, + both of you get nothing. +

+
+
diff --git a/bertrand/Decide.html b/bertrand/Decide.html new file mode 100644 index 0000000000000000000000000000000000000000..2f827e7c06a04fecf5415f9260dc303cfa8a2b31 --- /dev/null +++ b/bertrand/Decide.html @@ -0,0 +1,11 @@ +{{ block title }}Set Your Price{{ endblock }} +{{ block content }} + + {{ formfields }} + + +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/bertrand/Introduction.html b/bertrand/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..9d7b251fdbb167084c2a10f572ebb528c742672b --- /dev/null +++ b/bertrand/Introduction.html @@ -0,0 +1,7 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ {{ include_sibling 'instructions.html' }} + {{ next_button }} +{{ endblock }} diff --git a/bertrand/Results.html b/bertrand/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..4ef96cdcfbb9657c8519a49076327e8e20f7dc81 --- /dev/null +++ b/bertrand/Results.html @@ -0,0 +1,26 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + + + + + + + + + + + + + + + + + +
Your price{{ player.price }}
Lowest price{{ group.winning_price }}
Was your product sold?{{ if player.is_winner }} Yes {{ else }} No {{ endif }}
Your payoff{{ player.payoff }}
+ + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/bertrand/__init__.py b/bertrand/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f834914b72720821e558e4925b6ced1a48f10f90 --- /dev/null +++ b/bertrand/__init__.py @@ -0,0 +1,73 @@ +from otree.api import * + + + +doc = """ +2 firms complete in a market by setting prices for homogenous goods. +See "Kruse, J. B., Rassenti, S., Reynolds, S. S., & Smith, V. L. (1994). +Bertrand-Edgeworth competition in experimental markets. +Econometrica: Journal of the Econometric Society, 343-371." +""" + + +class C(BaseConstants): + PLAYERS_PER_GROUP = 2 + NAME_IN_URL = 'bertrand' + NUM_ROUNDS = 1 + MAXIMUM_PRICE = cu(100) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + winning_price = models.CurrencyField() + + +class Player(BasePlayer): + price = models.CurrencyField( + min=0, + max=C.MAXIMUM_PRICE, + doc="""Price player offers to sell product for""", + label="Please enter an amount from 0 to 100 as your price", + ) + is_winner = models.BooleanField() + + +# FUNCTIONS +def set_payoffs(group: Group): + import random + + players = group.get_players() + group.winning_price = min([p.price for p in players]) + winners = [p for p in players if p.price == group.winning_price] + winner = random.choice(winners) + for p in players: + if p == winner: + p.is_winner = True + p.payoff = p.price + else: + p.is_winner = False + p.payoff = cu(0) + + +# PAGES +class Introduction(Page): + pass + + +class Decide(Page): + form_model = 'player' + form_fields = ['price'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + pass + + +page_sequence = [Introduction, Decide, ResultsWaitPage, Results] diff --git a/bertrand/instructions.html b/bertrand/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..0ad1f922a706bc92d0321ceed6867a815a8bda28 --- /dev/null +++ b/bertrand/instructions.html @@ -0,0 +1,19 @@ + +
+ +

+ Instructions +

+

+ You have been randomly and anonymously paired with another participant. + Each of you will represent a firm. Each firm manufactures one unit of + the same product at no cost. +

+

+ Each of you privately sets your price, anything from 0 to {{ C.MAXIMUM_PRICE }}. + The buyer in the market will always buy one unit of the product at the + lower price. In case of a tie, the buyer will buy from one of you at + random. Your profit is your price if your product is sold and zero + otherwise. +

+
diff --git a/common_value_auction/Bid.html b/common_value_auction/Bid.html new file mode 100644 index 0000000000000000000000000000000000000000..dbde8956535d7cdef7178cb8ae6ee02278743476 --- /dev/null +++ b/common_value_auction/Bid.html @@ -0,0 +1,19 @@ +{{ block title }}Bid{{ endblock }} +{{ block content }} + +

+ The value of the item is estimated to be {{ player.item_value_estimate }}. + This estimate may deviate from the actual value by at most {{ C.BID_NOISE }}. +

+ +

+ Please make your bid now. The amount can be between {{ C.BID_MIN }} and {{ C.BID_MAX }}, inclusive. +

+ + {{ formfields }} + + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/common_value_auction/Introduction.html b/common_value_auction/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..52eddf88a85f8259821c89299be1ed9f52de05bd --- /dev/null +++ b/common_value_auction/Introduction.html @@ -0,0 +1,10 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/common_value_auction/Results.html b/common_value_auction/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..32560b4e055600333267d7f1787593fb8939cafc --- /dev/null +++ b/common_value_auction/Results.html @@ -0,0 +1,37 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + +

+ {{ if player.is_winner }} + You won the auction! + {{ if is_greedy }} + However, your bid amount was higher than the actual value of the item. Your payoff is therefore zero. + {{ elif player.payoff == 0 }} + Your payoff, however, is zero. + {{ endif }} + {{ else }} + You did not win the auction. + {{ endif }} +

+ + + + + + + + + + + + + + + + + +
Your bidWinning bidActual valueYour payoff
{{ player.bid_amount }}{{ group.highest_bid }}{{ group.item_value }}{{ player.payoff }}
+ + {{ next_button }} + +{{ endblock }} diff --git a/common_value_auction/__init__.py b/common_value_auction/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1095090de9062873c75cb7fac9216cc1febcfd3a --- /dev/null +++ b/common_value_auction/__init__.py @@ -0,0 +1,122 @@ +from otree.api import * + + +doc = """ +In a common value auction game, players simultaneously bid on the item being +auctioned.
+Prior to bidding, they are given an estimate of the actual value of the item. +This actual value is revealed after the bidding.
+Bids are private. The player with the highest bid wins the auction, but +payoff depends on the bid amount and the actual value.
+""" + + +class C(BaseConstants): + NAME_IN_URL = 'common_value_auction' + PLAYERS_PER_GROUP = None + NUM_ROUNDS = 1 + BID_MIN = cu(0) + BID_MAX = cu(10) + # Error margin for the value estimates shown to the players + BID_NOISE = cu(1) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + item_value = models.CurrencyField( + doc="""Common value of the item to be auctioned, random for treatment""" + ) + highest_bid = models.CurrencyField() + + +class Player(BasePlayer): + item_value_estimate = models.CurrencyField( + doc="""Estimate of the common value, may be different for each player""" + ) + bid_amount = models.CurrencyField( + min=C.BID_MIN, + max=C.BID_MAX, + doc="""Amount bidded by the player""", + label="Bid amount", + ) + is_winner = models.BooleanField( + initial=False, doc="""Indicates whether the player is the winner""" + ) + + +# FUNCTIONS +def creating_session(subsession: Subsession): + for g in subsession.get_groups(): + import random + + item_value = random.uniform(C.BID_MIN, C.BID_MAX) + g.item_value = round(item_value, 1) + + +def set_winner(group: Group): + import random + + players = group.get_players() + group.highest_bid = max([p.bid_amount for p in players]) + players_with_highest_bid = [p for p in players if p.bid_amount == group.highest_bid] + winner = random.choice( + players_with_highest_bid + ) # if tie, winner is chosen at random + winner.is_winner = True + for p in players: + set_payoff(p) + + +def generate_value_estimate(group: Group): + import random + + estimate = group.item_value + random.uniform(-C.BID_NOISE, C.BID_NOISE) + estimate = round(estimate, 1) + if estimate < C.BID_MIN: + estimate = C.BID_MIN + if estimate > C.BID_MAX: + estimate = C.BID_MAX + return estimate + + +def set_payoff(player: Player): + group = player.group + + if player.is_winner: + player.payoff = group.item_value - player.bid_amount + if player.payoff < 0: + player.payoff = 0 + else: + player.payoff = 0 + + +# PAGES +class Introduction(Page): + @staticmethod + def before_next_page(player: Player, timeout_happened): + group = player.group + + player.item_value_estimate = generate_value_estimate(group) + + +class Bid(Page): + form_model = 'player' + form_fields = ['bid_amount'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_winner + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(is_greedy=group.item_value - player.bid_amount < 0) + + +page_sequence = [Introduction, Bid, ResultsWaitPage, Results] diff --git a/common_value_auction/instructions.html b/common_value_auction/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..39be9f68f8686b2c9add7fc199443eed91f7b904 --- /dev/null +++ b/common_value_auction/instructions.html @@ -0,0 +1,35 @@ + +
+
+

+ Instructions +

+

+ You have been randomly and anonymously grouped with other players. + All players see these same instructions. +

+ +

+ Your task will be to bid for an item that is being auctioned. + Prior to bidding, each player will be given an estimate of the actual + value of the item. The estimates may be different between players. + The actual value of the item, which is common to all players, will be + revealed after the bidding has taken place. +

+ +

+ Based on the value estimate, each player will submit a single bid + within a given range. + All bids are private and submitted at the same time. +

+ +

+ The highest bidder will receive the actual value of the item as payoff + minus their own bid amount. + If the winner's bid amount is higher than the actual value of the item, + the payoff will be zero. + In the event of a tie between two or more players, the winner will be + chosen at random. Other players will receive nothing. +

+
+
diff --git a/cournot/Decide.html b/cournot/Decide.html new file mode 100644 index 0000000000000000000000000000000000000000..4c835353974f9359b6252ba9a888b28cd565d4ba --- /dev/null +++ b/cournot/Decide.html @@ -0,0 +1,10 @@ +{{ block title }}Production{{ endblock }} +{{ block content }} + + {{ formfields }} + + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/cournot/Introduction.html b/cournot/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..52eddf88a85f8259821c89299be1ed9f52de05bd --- /dev/null +++ b/cournot/Introduction.html @@ -0,0 +1,10 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/cournot/Results.html b/cournot/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..4462d90902ee2e7c08e128624a6f50ebb77b7039 --- /dev/null +++ b/cournot/Results.html @@ -0,0 +1,39 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + +

The results are shown in the following table.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Your firm produced:{{ player.units }} units
The other firm produced:{{ other_player_units }} units
Total production:{{ group.total_units }} units
Unit selling price:{{ C.TOTAL_CAPACITY }} – {{ group.total_units }} = {{ group.unit_price }}
Your profit:{{ player.payoff }}
+ + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/cournot/__init__.py b/cournot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..db0201ce38a0064d73ed15febb2cd0ecbcd5448e --- /dev/null +++ b/cournot/__init__.py @@ -0,0 +1,73 @@ +from otree.api import * + + + +doc = """ +In Cournot competition, firms simultaneously decide the units of products to +manufacture. The unit selling price depends on the total units produced. In +this implementation, there are 2 firms competing for 1 period. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'cournot' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + # Total production capacity of all players + TOTAL_CAPACITY = 60 + MAX_UNITS_PER_PLAYER = int(TOTAL_CAPACITY / PLAYERS_PER_GROUP) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + unit_price = models.CurrencyField() + total_units = models.IntegerField(doc="""Total units produced by all players""") + + +class Player(BasePlayer): + units = models.IntegerField( + min=0, + max=C.MAX_UNITS_PER_PLAYER, + doc="""Quantity of units to produce""", + label="How many units will you produce (from 0 to 30)?", + ) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + group.total_units = sum([p.units for p in players]) + group.unit_price = C.TOTAL_CAPACITY - group.total_units + for p in players: + p.payoff = group.unit_price * p.units + + +def other_player(player: Player): + return player.get_others_in_group()[0] + + +# PAGES +class Introduction(Page): + pass + + +class Decide(Page): + form_model = 'player' + form_fields = ['units'] + + +class ResultsWaitPage(WaitPage): + body_text = "Waiting for the other participant to decide." + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + return dict(other_player_units=other_player(player).units) + + +page_sequence = [Introduction, Decide, ResultsWaitPage, Results] diff --git a/cournot/instructions.html b/cournot/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..56f87557571e09be6fdbbda9958aa6fc41aa88c1 --- /dev/null +++ b/cournot/instructions.html @@ -0,0 +1,42 @@ + +
+
+ +

+ Instructions +

+ +

+ You have been randomly and anonymously paired with another participant. + Each of you will represent a firm. Both firms manufacture the same product. +

+ +

+ The two of you decide simultaneously and independently how many units to manufacture. + Your choices can be any number from 0 to {{ C.MAX_UNITS_PER_PLAYER }}. + All produced units will be sold, but the more is produced, the lower the unit selling price will be. +

+ + +

+ The unit selling price is: +

+ +
    + Unit selling price = {{ C.TOTAL_CAPACITY }} – Units produced by your firm – Units produced by the other firm +
+ +

+ Your profit is therefore: +

+ +
    + Your profit = Unit selling price × Units produced by your firm +
+ +

+ For your convenience, these instructions will remain available to you on all subsequent screens of this study. +

+ +
+
\ No newline at end of file diff --git a/dictator/Introduction.html b/dictator/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..9d7b251fdbb167084c2a10f572ebb528c742672b --- /dev/null +++ b/dictator/Introduction.html @@ -0,0 +1,7 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ {{ include_sibling 'instructions.html' }} + {{ next_button }} +{{ endblock }} diff --git a/dictator/Offer.html b/dictator/Offer.html new file mode 100644 index 0000000000000000000000000000000000000000..61d431226c1e4ba5f45503988292b0e41438f44f --- /dev/null +++ b/dictator/Offer.html @@ -0,0 +1,15 @@ +{{ block title }}Your Decision{{ endblock }} +{{ block content }} +

+ You are Participant 1. + Please decide how much of the {{ C.ENDOWMENT }} + you will keep for yourself. +

+ + {{ formfields }} + + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/dictator/Results.html b/dictator/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..7aed13caba7fede08815c7487827f71191e934a6 --- /dev/null +++ b/dictator/Results.html @@ -0,0 +1,15 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} +

+ + {{ if player.id_in_group == 1 }} + You decided to keep {{ group.kept }} for yourself. + {{ else }} + Participant 1 decided to keep {{ group.kept }}, so + you got {{ offer }}. + {{ endif }} + + {{ next_button }} +

+ {{ include_sibling 'instructions.html' }} +{{ endblock }} diff --git a/dictator/__init__.py b/dictator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8033d6886257259e072c4e11a520229ae8ae5832 --- /dev/null +++ b/dictator/__init__.py @@ -0,0 +1,73 @@ +from otree.api import * + + + +doc = """ +One player decides how to divide a certain amount between himself and the other +player. +See: Kahneman, Daniel, Jack L. Knetsch, and Richard H. Thaler. "Fairness +and the assumptions of economics." Journal of business (1986): +S285-S300. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'dictator' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + # Initial amount allocated to the dictator + ENDOWMENT = cu(100) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + kept = models.CurrencyField( + doc="""Amount dictator decided to keep for himself""", + min=0, + max=C.ENDOWMENT, + label="I will keep", + ) + + +class Player(BasePlayer): + pass + + +# FUNCTIONS +def set_payoffs(group: Group): + p1 = group.get_player_by_id(1) + p2 = group.get_player_by_id(2) + p1.payoff = group.kept + p2.payoff = C.ENDOWMENT - group.kept + + +# PAGES +class Introduction(Page): + pass + + +class Offer(Page): + form_model = 'group' + form_fields = ['kept'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 1 + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(offer=C.ENDOWMENT - group.kept) + + +page_sequence = [Introduction, Offer, ResultsWaitPage, Results] diff --git a/dictator/instructions.html b/dictator/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..6044aaab81729641ba9cb287d42950e90177e66b --- /dev/null +++ b/dictator/instructions.html @@ -0,0 +1,20 @@ + +
+
+

+ Instructions +

+ +

+ You will be paired randomly and anonymously with another participant. + In this study, one of you will be Participant 1 and the other + Participant 2. Prior to making a decision, you will learn your role, + which will be randomly assigned. +

+

+ There is {{ C.ENDOWMENT }} to split. Participant 1 will + decide how much she or he will retain. Then the rest will go to + Participant 2. +

+
+
\ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000000000000000000000000000000000000..9af068bb4de45848da487925a104a1420e7599da --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[entrypoint] $*" +} + +DB_URL="${OTREE_DATABASE_URL:-sqlite:////data/otree.sqlite3}" +DB_PATH="" + +if [[ "$DB_URL" == sqlite:////* ]]; then + DB_PATH="/${DB_URL#sqlite:////}" +elif [[ "$DB_URL" == sqlite:///* ]]; then + DB_PATH="${DB_URL#sqlite:///}" +fi + +if [[ -n "$DB_PATH" ]]; then + DB_DIR=$(dirname "$DB_PATH") + mkdir -p "$DB_DIR" + if [[ ! -f "$DB_PATH" ]]; then + log "Initializing SQLite database at $DB_PATH" + otree resetdb --noinput + fi +fi + +if [[ "${SKIP_COLLECTSTATIC:-0}" != "1" ]]; then + log "Collecting static assets" + otree collectstatic --noinput +fi + +PORT="${PORT:-8000}" +log "Starting oTree prodserver on 0.0.0.0:${PORT}" +exec otree prodserver1of2 0.0.0.0:${PORT} diff --git a/guess_two_thirds/Guess.html b/guess_two_thirds/Guess.html new file mode 100644 index 0000000000000000000000000000000000000000..9ad76bf45ec554f9083d8d363990176277919d13 --- /dev/null +++ b/guess_two_thirds/Guess.html @@ -0,0 +1,16 @@ +{{ block title }}Your Guess{{ endblock }} +{{ block content }} + + {{ if player.round_number > 1 }} +

+ Here were the two-thirds-average values in previous rounds: + {{ two_thirds_avg_history }} +

+ {{ endif }} + + {{ formfields }} + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/guess_two_thirds/Introduction.html b/guess_two_thirds/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..460c7dfc4c6a46a6a97966244ff79e50f7e6edbd --- /dev/null +++ b/guess_two_thirds/Introduction.html @@ -0,0 +1,10 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/guess_two_thirds/Results.html b/guess_two_thirds/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..ce86f44dd7551806f771583fdce35cf0e0278507 --- /dev/null +++ b/guess_two_thirds/Results.html @@ -0,0 +1,35 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + +

Here were the numbers guessed:

+ +

+ {{ sorted_guesses }} +

+ +

+ Two-thirds of the average of these numbers is {{ group.two_thirds_avg }}; + the closest guess was {{ group.best_guess }}. +

+ +

Your guess was {{ player.guess }}.

+ +

+ {{ if player.is_winner }} + {{ if group.num_winners > 1 }} + Therefore, you are one of the {{ group.num_winners }} winners + who tied for the best guess. + {{ else }} + Therefore, you win! + {{ endif }} + {{ else }} + Therefore, you did not win. + {{ endif }} + Your payoff is {{ player.payoff }}. +

+ + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/guess_two_thirds/__init__.py b/guess_two_thirds/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..af1806d1f4fb5293cc08d91ae3800b9963fc412c --- /dev/null +++ b/guess_two_thirds/__init__.py @@ -0,0 +1,86 @@ +from otree.api import * + + +doc = """ +a.k.a. Keynesian beauty contest. +Players all guess a number; whoever guesses closest to +2/3 of the average wins. +See https://en.wikipedia.org/wiki/Guess_2/3_of_the_average +""" + + +class C(BaseConstants): + PLAYERS_PER_GROUP = 3 + NUM_ROUNDS = 3 + NAME_IN_URL = 'guess_two_thirds' + JACKPOT = cu(100) + GUESS_MAX = 100 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + two_thirds_avg = models.FloatField() + best_guess = models.IntegerField() + num_winners = models.IntegerField() + + +class Player(BasePlayer): + guess = models.IntegerField( + min=0, max=C.GUESS_MAX, label="Please pick a number from 0 to 100:" + ) + is_winner = models.BooleanField(initial=False) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + guesses = [p.guess for p in players] + two_thirds_avg = (2 / 3) * sum(guesses) / len(players) + group.two_thirds_avg = round(two_thirds_avg, 2) + group.best_guess = min(guesses, key=lambda guess: abs(guess - group.two_thirds_avg)) + winners = [p for p in players if p.guess == group.best_guess] + group.num_winners = len(winners) + for p in winners: + p.is_winner = True + p.payoff = C.JACKPOT / group.num_winners + + +def two_thirds_avg_history(group: Group): + return [g.two_thirds_avg for g in group.in_previous_rounds()] + + +# PAGES +class Introduction(Page): + @staticmethod + def is_displayed(player: Player): + return player.round_number == 1 + + +class Guess(Page): + form_model = 'player' + form_fields = ['guess'] + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(two_thirds_avg_history=two_thirds_avg_history(group)) + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + group = player.group + + sorted_guesses = sorted(p.guess for p in group.get_players()) + return dict(sorted_guesses=sorted_guesses) + + +page_sequence = [Introduction, Guess, ResultsWaitPage, Results] diff --git a/guess_two_thirds/instructions.html b/guess_two_thirds/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..e7ee766ac5d1dc21966c303ffe95db21e7e88aec --- /dev/null +++ b/guess_two_thirds/instructions.html @@ -0,0 +1,27 @@ + +
+
+ +

+ Instructions +

+ +

+ You are in a group of {{ C.PLAYERS_PER_GROUP }} people. + Each of you will be asked to choose a + number between 0 and {{ C.GUESS_MAX }}. + The winner will be the participant whose + number is closest to 2/3 of the + average of all chosen numbers. +

+ +

+ The winner will receive {{ C.JACKPOT }}. + In case of a tie, the {{ C.JACKPOT }} + will be equally divided among winners. +

+ +

This game will be played for {{ C.NUM_ROUNDS }} rounds.

+ +
+
\ No newline at end of file diff --git a/matching_pennies/Choice.html b/matching_pennies/Choice.html new file mode 100644 index 0000000000000000000000000000000000000000..a91058e0f2cc5536ecb75839d67afc2406d6c1a6 --- /dev/null +++ b/matching_pennies/Choice.html @@ -0,0 +1,43 @@ +{{ block title }}Round {{ subsession.round_number }} of {{ C.NUM_ROUNDS }}{{ endblock }} +{{ block content }} + +

Instructions

+

+ This is a matching pennies game. + Player 1 is the 'Mismatcher' and wins if the choices mismatch; + Player 2 is the 'Matcher' and wins if they match. + +

+ +

+ At the end, a random round will be chosen for payment. +

+ +

+ +

Round history

+ + + + + + {{ for p in player_in_previous_rounds }} + + + + + {{ endfor }} +
RoundPlayer and outcome
{{ p.round_number }} + You were the {{ p.role }} + and {{ if p.is_winner }} won {{ else }} lost {{ endif }} +
+ +

+ In this round, you are the {{ player.role }}. +

+ + {{ formfields }} + + {{ next_button }} + +{{ endblock }} diff --git a/matching_pennies/ResultsSummary.html b/matching_pennies/ResultsSummary.html new file mode 100644 index 0000000000000000000000000000000000000000..171faa113c6eb0ce194c616e89cb479fd6e422d3 --- /dev/null +++ b/matching_pennies/ResultsSummary.html @@ -0,0 +1,25 @@ +{{ block title }}Final results{{ endblock }} +{{ block content }} + + + + + + + {{ for p in player_in_all_rounds }} + + + + + {{ endfor }} +
RoundPlayer and outcome
{{ p.round_number }} + You were the {{ p.role }} + and {{ if p.is_winner }} won {{ else }} lost {{ endif }} +
+ +

+ The paying round was {{ paying_round }}. + Your total payoff is therefore {{ total_payoff }}. +

+ +{{ endblock }} diff --git a/matching_pennies/__init__.py b/matching_pennies/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c5084af9d6eb1bbf4658d5360e0c84231d9d18a4 --- /dev/null +++ b/matching_pennies/__init__.py @@ -0,0 +1,100 @@ +from otree.api import * + + +doc = """ +A demo of how rounds work in oTree, in the context of 'matching pennies' +""" + + +class C(BaseConstants): + NAME_IN_URL = 'matching_pennies' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 4 + STAKES = cu(100) + + MATCHER_ROLE = 'Matcher' + MISMATCHER_ROLE = 'Mismatcher' + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + penny_side = models.StringField( + choices=[['Heads', 'Heads'], ['Tails', 'Tails']], + widget=widgets.RadioSelect, + label="I choose:", + ) + is_winner = models.BooleanField() + + +# FUNCTIONS +def creating_session(subsession: Subsession): + session = subsession.session + import random + + if subsession.round_number == 1: + paying_round = random.randint(1, C.NUM_ROUNDS) + session.vars['paying_round'] = paying_round + if subsession.round_number == 3: + # reverse the roles + matrix = subsession.get_group_matrix() + for row in matrix: + row.reverse() + subsession.set_group_matrix(matrix) + if subsession.round_number > 3: + subsession.group_like_round(3) + + +def set_payoffs(group: Group): + subsession = group.subsession + session = group.session + + p1 = group.get_player_by_id(1) + p2 = group.get_player_by_id(2) + for p in [p1, p2]: + is_matcher = p.role == C.MATCHER_ROLE + p.is_winner = (p1.penny_side == p2.penny_side) == is_matcher + if subsession.round_number == session.vars['paying_round'] and p.is_winner: + p.payoff = C.STAKES + else: + p.payoff = cu(0) + + +# PAGES +class Choice(Page): + form_model = 'player' + form_fields = ['penny_side'] + + @staticmethod + def vars_for_template(player: Player): + return dict(player_in_previous_rounds=player.in_previous_rounds()) + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class ResultsSummary(Page): + @staticmethod + def is_displayed(player: Player): + return player.round_number == C.NUM_ROUNDS + + @staticmethod + def vars_for_template(player: Player): + session = player.session + + player_in_all_rounds = player.in_all_rounds() + return dict( + total_payoff=sum([p.payoff for p in player_in_all_rounds]), + paying_round=session.vars['paying_round'], + player_in_all_rounds=player_in_all_rounds, + ) + + +page_sequence = [Choice, ResultsWaitPage, ResultsSummary] diff --git a/payment_info/PaymentInfo.html b/payment_info/PaymentInfo.html new file mode 100644 index 0000000000000000000000000000000000000000..0ac2eef34ee7fd37c0f457cccbe118dc1f306795 --- /dev/null +++ b/payment_info/PaymentInfo.html @@ -0,0 +1,30 @@ +{{ block title }}Thank you{{ endblock }} +{{ block content }} + +

+ Below are examples of messages that could be displayed for different experimental settings. +

+ +
+
+

Laboratory:

+

Please remain seated until your number is called. Then take your number card, and proceed to the cashier.

+

Note: For the cashier in the laboratory, oTree can print a list of payments for all participants as a PDF.

+
+
+ +
+
+

Classroom:

+

If you want to keep track of how students did, the easiest thing is to assign the starting links to students by name. + It is even possible to give each student a single permanent link for a whole semester using Rooms; + so no need to waste time in each lecture with handing out new links and keeping track of which student uses which link. + Alternatively, you may just give + students anonymous links or secret nicknames. +

+
+
+ + +{{ endblock }} + diff --git a/payment_info/__init__.py b/payment_info/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bd9aa1e68ef0736ca97f05c905d311f2d57e062c --- /dev/null +++ b/payment_info/__init__.py @@ -0,0 +1,38 @@ +from otree.api import * + + + +doc = """ +This application provides a webpage instructing participants how to get paid. +Examples are given for the lab and Amazon Mechanical Turk (AMT). +""" + + +class C(BaseConstants): + NAME_IN_URL = 'payment_info' + PLAYERS_PER_GROUP = None + NUM_ROUNDS = 1 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + pass + + +# FUNCTIONS +# PAGES +class PaymentInfo(Page): + @staticmethod + def vars_for_template(player: Player): + participant = player.participant + return dict(redemption_code=participant.label or participant.code) + + +page_sequence = [PaymentInfo] diff --git a/payment_info/tests.py b/payment_info/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..c03a23e15a8d15cd7da45340ee2508c4f8b014f1 --- /dev/null +++ b/payment_info/tests.py @@ -0,0 +1,8 @@ +from otree.api import Bot + + +class PlayerBot(Bot): + def play_round(self): + # No inputs; placeholder bot to satisfy session testing + pass + diff --git a/policy_dictator_norms/Introduction.html b/policy_dictator_norms/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..bbd208c4165de34031c79791d297d3d6404117a3 --- /dev/null +++ b/policy_dictator_norms/Introduction.html @@ -0,0 +1,16 @@ +{{ block title }}Introduction — Game 1 of 3 (Policy/Bias){{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+

+ You are assigned to represent Neighborhood A in a one-time relief allocation. Another participant represents + Neighborhood B. Both neighborhoods are equally impacted and eligible. +

+

+ You must allocate a limited budget between A (your group) and B (the other participant’s group). Every point + you allocate to A reduces the amount for B by one point, and vice versa. Your choice determines both payouts. + Points convert to a bonus at the end. +

+ {{ include_sibling 'instructions.html' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_dictator_norms/Offer.html b/policy_dictator_norms/Offer.html new file mode 100644 index 0000000000000000000000000000000000000000..ae1f292d39c0feecd1c96bf9efe1729166c63020 --- /dev/null +++ b/policy_dictator_norms/Offer.html @@ -0,0 +1,18 @@ +{{ block title }}Your Decision{{ endblock }} +{{ block content }} +

+ Your endowment: {{ C.ENDOWMENT }} points. You are Participant 1, representing + Neighborhood A. The other participant represents Neighborhood B. Both neighborhoods are equally impacted. +

+

+ Decide how many points to allocate to Neighborhood A (kept by you). The remainder automatically goes to + Neighborhood B (paid to the other participant). Points convert to a bonus at the end of the study. +

+ + {{ formfields }} + + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/policy_dictator_norms/Results.html b/policy_dictator_norms/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..d9b4b7d1d958c40a08ecfd7b0a6d2d1fa8dfa80c --- /dev/null +++ b/policy_dictator_norms/Results.html @@ -0,0 +1,12 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} +

+ Allocation to Neighborhood A (Participant 1): {{ group.kept }} points.
+ Allocation to Neighborhood B (Participant 2): {{ offer }} points. +

+

+ Your total payoff: {{ player.payoff }}. +

+

Proceed to the next task.

+ {{ next_button }} +{{ endblock }} diff --git a/policy_dictator_norms/__init__.py b/policy_dictator_norms/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8aa604d9aff42a32abe81fd12563bea8a4cf3727 --- /dev/null +++ b/policy_dictator_norms/__init__.py @@ -0,0 +1,73 @@ +from otree.api import * + + + +doc = """ +One player decides how to divide a certain amount between himself and the other +player. +See: Kahneman, Daniel, Jack L. Knetsch, and Richard H. Thaler. "Fairness +and the assumptions of economics." Journal of business (1986): +S285-S300. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'policy_dictator_norms' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + # Initial amount allocated to the dictator + ENDOWMENT = cu(100) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + kept = models.CurrencyField( + doc="""Amount dictator decided to keep for himself""", + min=0, + max=C.ENDOWMENT, + label="I will keep", + ) + + +class Player(BasePlayer): + pass + + +# FUNCTIONS +def set_payoffs(group: Group): + p1 = group.get_player_by_id(1) + p2 = group.get_player_by_id(2) + p1.payoff = group.kept + p2.payoff = C.ENDOWMENT - group.kept + + +# PAGES +class Introduction(Page): + pass + + +class Offer(Page): + form_model = 'group' + form_fields = ['kept'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 1 + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(offer=C.ENDOWMENT - group.kept) + + +page_sequence = [Introduction, Offer, ResultsWaitPage, Results] diff --git a/policy_dictator_norms/instructions.html b/policy_dictator_norms/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..158254291f2e09bac8f7b1aa3a66e92b76bd348f --- /dev/null +++ b/policy_dictator_norms/instructions.html @@ -0,0 +1,26 @@ + +
+
+

Instructions

+ +

+ Policy setting: one-time relief split between two equally impacted neighborhoods (A and B). + Every point allocated to A reduces B by one point, and vice versa. +

+ +

+ You are randomly and anonymously paired. One of you is Participant 1 (allocates), + the other is Participant 2 (receives what is not kept by Participant 1). + Roles are assigned at random and shown before any decision. +

+

+ There are {{ C.ENDOWMENT }} points to allocate between two equally needy groups: Neighborhood A and B. + Participant 1 represents Neighborhood A and decides how many points to allocate to A (these points are kept + by Participant 1). The remainder automatically goes to Neighborhood B (paid to Participant 2). Points convert + to a bonus at the end of the study. +

+

+ Both participants see the same rules. Participant 2 observes the final split on the results screen. +

+
+
diff --git a/policy_dictator_norms/tests.py b/policy_dictator_norms/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..8029d8d738ae89075ca5650fb75d38cdbda0892f --- /dev/null +++ b/policy_dictator_norms/tests.py @@ -0,0 +1,16 @@ +from otree.api import Bot, Submission, expect, cu +from . import Introduction, Offer, Results + + +class PlayerBot(Bot): + def play_round(self): + yield Introduction + if self.player.id_in_group == 1: + yield Submission(Offer, dict(kept=cu(60))) + yield Results + # Validate payoffs + p1 = self.group.get_player_by_id(1) + p2 = self.group.get_player_by_id(2) + expect(p1.payoff, cu(60)) + expect(p2.payoff, cu(40)) + diff --git a/policy_guess_anchoring/Guess.html b/policy_guess_anchoring/Guess.html new file mode 100644 index 0000000000000000000000000000000000000000..9616fb8e3a6219c85ca7d33e24f84178428c2d83 --- /dev/null +++ b/policy_guess_anchoring/Guess.html @@ -0,0 +1,15 @@ +{{ block title }}Round {{ player.round_number }} — Your Guess{{ endblock }} +{{ block content }} + + {{ if participant.show_anchor }} +

+ As a point of reference this round, some people start by thinking about the number + {{ participant.anchor_value }}. You are free to choose any number you wish. +

+ {{ endif }} + + {{ formfields }} + + {{ next_button }} + +{{ endblock }} diff --git a/policy_guess_anchoring/Introduction.html b/policy_guess_anchoring/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..5502ca05ef3dcf82888a2f4ec964ab2d9dbd4f6c --- /dev/null +++ b/policy_guess_anchoring/Introduction.html @@ -0,0 +1,22 @@ +{{ block title }}Guessing Game — Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ +

+ This is a guessing game. You will enter a number between 0 and {{ C.GUESS_MAX }}. The winning number is the one + closest to two‑thirds of the average of all guesses in your group. The winner gets a bonus. +

+ + {{ if participant.show_anchor }} +

+ As a point of reference, some people start by thinking about the number {{ participant.anchor_value }}. + You are free to choose any number you wish. +

+ {{ endif }} + + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/policy_guess_anchoring/Results.html b/policy_guess_anchoring/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..9b43fdd9e5715bbe7e6c125f163aa216198f3e83 --- /dev/null +++ b/policy_guess_anchoring/Results.html @@ -0,0 +1,47 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + +

+ The target number (two-thirds of the average) was {{ group.two_thirds_avg }}. + The winning guess, which was closest to the target, was {{ group.best_guess }}. +

+ + + + + + + + + + + {{ for p in players_in_group }} + + + + + + {{ endfor }} + +
ParticipantGuessAnchor Shown
Participant {{ p.id_in_group }} {{ if p.id_in_group == player.id_in_group }}(You){{ endif }}{{ p.guess }}{{ if p.participant.show_anchor }}{{ p.participant.anchor_value }}{{ else }}No{{ endif }}
+ +

+ {{ if player.is_winner }} + Your guess of {{ player.guess }} was closest to the target. + {{ if group.num_winners > 1 }} + You are one of {{ group.num_winners }} winners, and the prize is split. + {{ else }} + You are the sole winner! + {{ endif }} + {{ else }} + Your guess of {{ player.guess }} was not the closest. + {{ endif }} +

+

Your payoff for this round is {{ player.payoff }}.

+ +

Debrief: Notice how the guesses might differ between those who saw an anchor (20, 50, or 70) and those who didn't. + What would happen over many rounds if everyone learned from the previous average?

+ + {{ next_button }} + +{{ endblock }} diff --git a/policy_guess_anchoring/__init__.py b/policy_guess_anchoring/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2ac92dcb7dba1b12812a07ba52be10aff7e3fcfc --- /dev/null +++ b/policy_guess_anchoring/__init__.py @@ -0,0 +1,118 @@ +from __future__ import annotations +from otree.api import * + +def set_anchor(subsession: Subsession): + import random + # Assign within each group to ensure at least one anchored and one not, + # and choose an anchor value for this group/round. + for g in subsession.get_groups(): + players = g.get_players() + n = len(players) + if n <= 1: + flags = [False] * n + else: + num_true = max(1, min(n - 1, n // 2)) + flags = [True] * num_true + [False] * (n - num_true) + random.shuffle(flags) + anchor_value = random.choice([20, 50, 70]) + for p, flag in zip(players, flags): + p.participant.vars['show_anchor'] = flag + # Reassign each round; clear or set the value accordingly + p.participant.vars['anchor_value'] = anchor_value if flag else None + +doc = """ +a.k.a. Keynesian beauty contest. +Players all guess a number; whoever guesses closest to +2/3 of the average wins. +See https://en.wikipedia.org/wiki/Guess_2/3_of_the_average +""" + + +class C(BaseConstants): + PLAYERS_PER_GROUP = 3 + NUM_ROUNDS = 3 + NAME_IN_URL = 'policy_guess_anchoring' + JACKPOT = cu(100) + GUESS_MAX = 100 + + +class Subsession(BaseSubsession): + def creating_session(self): + # Reassign anchors each round + set_anchor(self) + + +# Also support oTree's module-level hook style +def creating_session(subsession: Subsession): + # Support module-level hook; reassign each round + set_anchor(subsession) + + +class Group(BaseGroup): + two_thirds_avg = models.FloatField() + best_guess = models.IntegerField() + num_winners = models.IntegerField() + + +class Player(BasePlayer): + guess = models.IntegerField( + min=0, max=C.GUESS_MAX, label="Please pick a number from 0 to 100:" + ) + is_winner = models.BooleanField(initial=False) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + guesses = [p.guess for p in players] + two_thirds_avg = (2 / 3) * sum(guesses) / len(players) + group.two_thirds_avg = round(two_thirds_avg, 2) + group.best_guess = min(guesses, key=lambda guess: abs(guess - group.two_thirds_avg)) + winners = [p for p in players if p.guess == group.best_guess] + group.num_winners = len(winners) + for p in winners: + p.is_winner = True + p.payoff = C.JACKPOT / group.num_winners + + +def two_thirds_avg_history(group: Group): + return [g.two_thirds_avg for g in group.in_previous_rounds()] + + +# PAGES +class Introduction(Page): + @staticmethod + def is_displayed(player: Player): + return player.round_number == 1 + + @staticmethod + def vars_for_template(player: Player): + # Guard against missing key to avoid template KeyError + player.participant.vars.setdefault('show_anchor', False) + player.participant.vars.setdefault('anchor_value', None) + return {} + + +class Guess(Page): + form_model = 'player' + form_fields = ['guess'] + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(two_thirds_avg_history=two_thirds_avg_history(group)) + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + group = player.group + return dict(players_in_group=group.get_players()) + + +page_sequence = [Introduction, Guess, ResultsWaitPage, Results] diff --git a/policy_guess_anchoring/instructions.html b/policy_guess_anchoring/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..3bd0c59bc39e322ebee5bd271db23bb7e466f110 --- /dev/null +++ b/policy_guess_anchoring/instructions.html @@ -0,0 +1,15 @@ +
+
+

Rules

+

+ This is a simple guessing game where you try to anticipate the choices of others. +

+
    +
  • Pick any integer between 0 and {{ C.GUESS_MAX }}.
  • +
  • The target number is two‑thirds of the average of all numbers submitted by your group.
  • +
  • The player with the closest guess wins a bonus prize. Ties are split equally.
  • +
  • There are {{ C.NUM_ROUNDS }} rounds. From round 2 onward, you will see the results from previous rounds.
  • +
+

Tip: Think about what numbers others might choose, and how their choices will affect the average.

+
+
diff --git a/policy_guess_anchoring/tests.py b/policy_guess_anchoring/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..571fef383ad2b4d340ce9621e68cddbd51aaa8dc --- /dev/null +++ b/policy_guess_anchoring/tests.py @@ -0,0 +1,21 @@ +from otree.api import Bot, Submission, expect +from . import Introduction, Guess, Results + + +class PlayerBot(Bot): + def play_round(self): + if self.player.round_number == 1: + yield Introduction + + # On round 1, player 1 will test the anchor randomization + if self.player.round_number == 1 and self.player.id_in_group == 1: + show_anchor_values = [p.participant.show_anchor for p in self.group.get_players()] + assert len(set(show_anchor_values)) > 1, f"Anchor randomization is not working. All players in group {self.group.id} have the same value. Values: {show_anchor_values}" + + # Deterministic guesses by id + guess_map = {1: 33, 2: 22, 3: 44} + yield Submission(Guess, dict(guess=guess_map[self.player.id_in_group])) + yield Results + if self.player.id_in_group == 2 and self.player.round_number == 1: + # On our pattern, 2/3 average is 22, so player 2 should win in round 1 + expect(self.player.is_winner, True) \ No newline at end of file diff --git a/policy_public_goods_defaults/Contribute.html b/policy_public_goods_defaults/Contribute.html new file mode 100644 index 0000000000000000000000000000000000000000..4ddff457d2fa3896f7ea9f3906003e1b9364b852 --- /dev/null +++ b/policy_public_goods_defaults/Contribute.html @@ -0,0 +1,21 @@ +{{ extends "global/Page.html" }} +{{ block title }}Contribute (Game 3 of 3){{ endblock }} +{{ block content }} + +

+ Your endowment: {{ C.ENDOWMENT }} points. Group size: {{ C.PLAYERS_PER_GROUP }}. + Matching rate: {{ C.MULTIPLIER }}× on the pooled fund. +

+

+ Policy setting: neighborhood resilience fund (cooling centers and filtration). Choose any amount from 0 to {{ C.ENDOWMENT }} + to contribute to the shared fund. Points convert to a bonus at the end. +

+ + + {{ formfields }} + + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/policy_public_goods_defaults/Introduction.html b/policy_public_goods_defaults/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..7dee500231ccb2038250cc91d23872dcf232aba2 --- /dev/null +++ b/policy_public_goods_defaults/Introduction.html @@ -0,0 +1,19 @@ +{{ block title }}Public Goods — Introduction (Game 3 of 3, Policy/Bias){{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+

+ You and two others are allocating to a neighborhood resilience fund (e.g., cooling centers and air filtration + grants). Contributions are pooled, matched by the city at {{ C.MULTIPLIER }}×, then shared equally among your group. +

+

+ Treat points as bonus-eligible. Choose as you would if deciding a real contribution in this program. +

+
    +
  • Group size: {{ C.PLAYERS_PER_GROUP }}. Each participant receives {{ C.ENDOWMENT }} points.
  • +
  • You may contribute any amount (0–{{ C.ENDOWMENT }}) to the shared fund.
  • +
  • The pooled fund is multiplied by {{ C.MULTIPLIER }} and split equally among all group members.
  • +
  • Your payoff = ({{ C.ENDOWMENT }} − your contribution) + your equal share of the multiplied fund.
  • +
+ {{ next_button }} +{{ endblock }} diff --git a/policy_public_goods_defaults/Results.html b/policy_public_goods_defaults/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..db673b06bf7789a0e89dcc2a8fb2cdacad4f5e35 --- /dev/null +++ b/policy_public_goods_defaults/Results.html @@ -0,0 +1,51 @@ +{{ block title }}Results Breakdown{{ endblock }} +{{ block content }} + +

Here is a summary of the round's outcome:

+ + + + + + + + + + + + + + +
Your initial endowment:{{ C.ENDOWMENT }}
Your contribution to the fund:{{ player.contribution }}
Amount you kept:{{ C.ENDOWMENT }} - {{ player.contribution }} = {{ kept }}
+ +

Fund Calculation

+ + + + + + + + + + + + + + + + + +
Total group contribution:{{ group.total_contribution }}
City matching multiplier:× {{ C.MULTIPLIER }}
Total amount in the fund:{{ group.total_contribution }} × {{ C.MULTIPLIER }} = {{ multiplied_total }}
Your share (divided by {{ C.PLAYERS_PER_GROUP }} participants):{{ multiplied_total }} / {{ C.PLAYERS_PER_GROUP }} = {{ group.individual_share }}
+ +

Final Payoff

+

+ Your final payoff is the amount you kept plus your share of the fund. +

+

+ {{ kept }} (kept) + {{ group.individual_share }} (share) = {{ player.payoff }} +

+ + {{ next_button }} + +{{ endblock }} diff --git a/policy_public_goods_defaults/__init__.py b/policy_public_goods_defaults/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0aa6db6581db064875ee190a56b5a07aef39478a --- /dev/null +++ b/policy_public_goods_defaults/__init__.py @@ -0,0 +1,68 @@ +from otree.api import * + + + +class C(BaseConstants): + NAME_IN_URL = 'policy_public_goods_defaults' + PLAYERS_PER_GROUP = 3 + NUM_ROUNDS = 1 + ENDOWMENT = cu(100) + MULTIPLIER = 1.8 + # Suggested contribution for nudge-only framing (no logic change) + SUGGESTED_CONTRIBUTION = cu(30) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + total_contribution = models.CurrencyField() + individual_share = models.CurrencyField() + + +class Player(BasePlayer): + contribution = models.CurrencyField( + min=0, max=C.ENDOWMENT, label="How much will you contribute?" + ) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + contributions = [p.contribution for p in players] + group.total_contribution = sum(contributions) + group.individual_share = ( + group.total_contribution * C.MULTIPLIER / C.PLAYERS_PER_GROUP + ) + for p in players: + p.payoff = C.ENDOWMENT - p.contribution + group.individual_share + + +# PAGES +class Introduction(Page): + pass + + +class Contribute(Page): + form_model = 'player' + form_fields = ['contribution'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + group = player.group + kept = C.ENDOWMENT - player.contribution + multiplied_total = group.total_contribution * C.MULTIPLIER + return dict( + kept=kept, + multiplied_total=multiplied_total, + ) + + +page_sequence = [Introduction, Contribute, ResultsWaitPage, Results] diff --git a/policy_public_goods_defaults/instructions.html b/policy_public_goods_defaults/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..dc773cc2988bca7b16d9563b6f039008d96f244e --- /dev/null +++ b/policy_public_goods_defaults/instructions.html @@ -0,0 +1,16 @@ +
+
+

Public Goods — Rules

+

+ Policy setting: neighborhood resilience fund (e.g., cooling centers and air filtration). Group + contributions are pooled, matched at {{ C.MULTIPLIER }}×, and shared equally. +

+
    +
  • Group size: {{ C.PLAYERS_PER_GROUP }}. Each participant receives {{ C.ENDOWMENT }} points.
  • +
  • Contribute any amount (0–{{ C.ENDOWMENT }}) to the neighborhood resilience fund.
  • +
  • The pooled fund is multiplied by {{ C.MULTIPLIER }} and split equally among all group members.
  • +
  • Your payoff = ({{ C.ENDOWMENT }} − your contribution) + your equal share of the multiplied fund.
  • +
+

Points convert to a bonus at the end. If a suggested amount appears, it is informational only.

+
+
diff --git a/policy_public_goods_defaults/tests.py b/policy_public_goods_defaults/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..42c09a48a724fc0341873fd75ff898b16c116260 --- /dev/null +++ b/policy_public_goods_defaults/tests.py @@ -0,0 +1,25 @@ +from otree.api import Bot, Submission, expect, cu +from . import Introduction, Contribute, Results + + +class PlayerBot(Bot): + def play_round(self): + yield Introduction + # Deterministic contributions by id + contrib_map = {1: cu(10), 2: cu(20), 3: cu(30)} + c = contrib_map[self.player.id_in_group] + yield Submission(Contribute, dict(contribution=c)) + + if self.player.id_in_group == 3: + # After all submit, check payoffs on results page + total = cu(10 + 20 + 30) + share = total * self.session.config_get('C_MULTIPLIER', default=None) if False else total * 1.8 / 3 + # Endowment 100 - contribution + share + expected_payoffs = { + 1: cu(100) - cu(10) + share, + 2: cu(100) - cu(20) + share, + 3: cu(100) - cu(30) + share, + } + for p in self.group.get_players(): + expect(p.payoff, expected_payoffs[p.id_in_group]) + yield Results diff --git a/policy_survey_biases/Availability.html b/policy_survey_biases/Availability.html new file mode 100644 index 0000000000000000000000000000000000000000..ad76db6a2bacb1e4c0fe2cbe6a1859beae9c802e --- /dev/null +++ b/policy_survey_biases/Availability.html @@ -0,0 +1,14 @@ +{{ block title }}Task: Recall and Priorities{{ endblock }} +{{ block content }} +

+ Policy setting: imagine you are prioritizing near-term investments. A recent news story may come to + mind—briefly recall it, then set priorities as you would for a real agenda. +

+

+ Briefly recall one concrete news story about climate or public health (1–2 sentences). + Then, rate two policy priorities for this year (1–7). +

+

Anchors: 1=not at all a priority … 7=very high priority.

+ {{ formfields }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/Confirmation.html b/policy_survey_biases/Confirmation.html new file mode 100644 index 0000000000000000000000000000000000000000..41470efa048ff361336d8376fbaf6858372bad20 --- /dev/null +++ b/policy_survey_biases/Confirmation.html @@ -0,0 +1,12 @@ +{{ block title }}Task: Information and Views{{ endblock }} +{{ block content }} +

+ In this module, you will see a short information item and a general statement on a policy topic. Depending on the + order assigned, you may share your view before or after reading the item. Your answers may or may not change; + both outcomes are informative. +

+

+ Note: The actual task screens will appear next. This page is an overview only. +

+ {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ConfirmationA_InfoBefore.html b/policy_survey_biases/ConfirmationA_InfoBefore.html new file mode 100644 index 0000000000000000000000000000000000000000..e8c7248ee6cf212bc1efb13e1e1a4d97a62d44df --- /dev/null +++ b/policy_survey_biases/ConfirmationA_InfoBefore.html @@ -0,0 +1,19 @@ +{{ block title }}Information and View{{ endblock }} +{{ block content }} +

+ Please review the following brief information item and then indicate your view on a general policy statement using a standard agree/disagree scale. +

+
+ Independent audits of a state rebate program reported household electricity use fell by 5–10% + over 12 months, on average, among participants. +
+

Quick check: According to the item above, what reduction range was reported?

+ {{ formfield 'comp_choice' }} +

+ Please indicate how much you agree or disagree with the following statement: + “Rebate programs are cost‑effective for taxpayers.”
+ Scale anchors: 1 = strongly disagree … 7 = strongly agree. +

+ {{ formfield 'agree_post' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ConfirmationA_Post.html b/policy_survey_biases/ConfirmationA_Post.html new file mode 100644 index 0000000000000000000000000000000000000000..5340db356cb4b8d0b56832b4ac876aa0e277bc60 --- /dev/null +++ b/policy_survey_biases/ConfirmationA_Post.html @@ -0,0 +1,10 @@ +{{ block title }}Your View (After Information){{ endblock }} +{{ block content }} +

+ Finally, after reviewing the information item, please indicate how much you agree or disagree with the statement: + “Rebate programs are cost‑effective for taxpayers.”
+ Scale anchors: 1 = strongly disagree … 7 = strongly agree. +

+ {{ formfield 'agree_post' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ConfirmationA_Pre.html b/policy_survey_biases/ConfirmationA_Pre.html new file mode 100644 index 0000000000000000000000000000000000000000..6fc76c81b9a184af00a44ac21149eb44d30b3459 --- /dev/null +++ b/policy_survey_biases/ConfirmationA_Pre.html @@ -0,0 +1,10 @@ +{{ block title }}Your View (Initial){{ endblock }} +{{ block content }} +

+ Before seeing the information item, please indicate how much you agree or disagree with the following statement: + “Rebate programs are cost‑effective for taxpayers.”
+ Scale anchors: 1 = strongly disagree … 7 = strongly agree. +

+ {{ formfield 'agree_pre' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ConfirmationA_Snippet.html b/policy_survey_biases/ConfirmationA_Snippet.html new file mode 100644 index 0000000000000000000000000000000000000000..fccc51d1bb487c67b67891f15bd6c9236544242c --- /dev/null +++ b/policy_survey_biases/ConfirmationA_Snippet.html @@ -0,0 +1,17 @@ +{{ block title }}Information Item{{ endblock }} +{{ block content }} +

+ Please review the following brief information item. Then, rate how accurate it seems and answer a quick check. +

+
+ Independent audits of a state rebate program reported household electricity use fell by 5–10% + over 12 months, on average, among participants. +
+

+ How accurate does this information seem? Please use a 1–7 scale (1 = not at all accurate … 7 = extremely accurate). +

+ {{ formfield 'snippet_truth' }} +

According to the item, what reduction range was reported?

+ {{ formfield 'comp_choice' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ModuleB_Eval.html b/policy_survey_biases/ModuleB_Eval.html new file mode 100644 index 0000000000000000000000000000000000000000..948c7c874e2bffa98f07be053604610625221d62 --- /dev/null +++ b/policy_survey_biases/ModuleB_Eval.html @@ -0,0 +1,21 @@ +{{ block title }}Statements and Evidence{{ endblock }} +{{ block content }} +

+ Policy target: an urban congestion pricing program (road user charge) to manage peak traffic and fund mobility. + Please review brief statements about this policy. Items deliberately include both supportive and critical arguments + and both accurate and inaccurate claims. Rate each item on its own merits: persuasiveness for arguments and + perceived accuracy for factual claims. +

+

Scale: 1 = not at all persuasive/accurate … 7 = extremely persuasive/accurate.

+

Arguments

+ {{ formfield 'arg1_strength' }} + {{ formfield 'arg2_strength' }} + {{ formfield 'arg3_strength' }} + {{ formfield 'arg4_strength' }} +

Factual claims

+ {{ formfield 'fact1_truth' }} + {{ formfield 'fact2_truth' }} + {{ formfield 'fact3_truth' }} + {{ formfield 'fact4_truth' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ModuleB_Support.html b/policy_survey_biases/ModuleB_Support.html new file mode 100644 index 0000000000000000000000000000000000000000..2f978ce647da620069097fe3dca0cc9d6064b1bd --- /dev/null +++ b/policy_survey_biases/ModuleB_Support.html @@ -0,0 +1,16 @@ +{{ block title }}General View{{ endblock }} +{{ block content }} +

+ Policy target: an urban congestion pricing program (road user charge) to manage peak traffic and fund mobility. + When forming your view, consider typical goals and trade‑offs: reducing congestion and travel times; reliability of + bus and emergency services; distributional impacts across neighborhoods and income groups; business activity and + delivery logistics; and the use of revenue (e.g., transit upgrades, fare reductions, rebates/discounts). +

+

+ Please answer as you would in a real advisory context where your recommendation informs a public brief. +

+

Prompt: Overall, how much do you support a congestion‑pricing program in your city/state?

+

Scale anchors: 1 = strongly oppose … 7 = strongly support.

+ {{ formfield 'support_policy' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ModuleC_Opinion.html b/policy_survey_biases/ModuleC_Opinion.html new file mode 100644 index 0000000000000000000000000000000000000000..4ac55bc12b218a51e8a00dc1bc9c1d840ba7a3b8 --- /dev/null +++ b/policy_survey_biases/ModuleC_Opinion.html @@ -0,0 +1,20 @@ +{{ block title }}Topics and Views{{ endblock }} +{{ block content }} +

+ Please share your views on two public investment areas and how you would prioritize them this year. When answering, + think about overall benefits, costs, and timing — including reliability, public safety, equity, and what + you would deprioritize if resources are limited. +

+

Scale anchors:

+
    +
  • Support: 1 = strongly oppose … 7 = strongly support
  • +
  • Priority: 1 = not a priority … 7 = very high priority
  • +
+

Support

+ {{ formfield 'support_energy' }} + {{ formfield 'support_public_health' }} +

Priority

+ {{ formfield 'priority_energy' }} + {{ formfield 'priority_public_health' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/ModuleC_Recall.html b/policy_survey_biases/ModuleC_Recall.html new file mode 100644 index 0000000000000000000000000000000000000000..5953dc5a8bfdad9c485c82bb31075b449245ac96 --- /dev/null +++ b/policy_survey_biases/ModuleC_Recall.html @@ -0,0 +1,15 @@ +{{ block title }}Recent News You Saw{{ endblock }} +{{ block content }} +

+ Think of specific news or information you personally saw on each topic. In 1–2 sentences, note the source (e.g., + headline gist + where you saw it), and why it stood out. Then indicate approximately how recent it was. There are + no right or wrong answers — we’re interested in what comes to mind. +

+

Grid reliability and energy

+ {{ formfield 'recall_energy_text' }} + {{ formfield 'recency_energy' }} +

Public health preparedness

+ {{ formfield 'recall_public_health_text' }} + {{ formfield 'recency_public_health' }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/Overconfidence.html b/policy_survey_biases/Overconfidence.html new file mode 100644 index 0000000000000000000000000000000000000000..276e5ac36c08ebe0933261824bc7710902db4898 --- /dev/null +++ b/policy_survey_biases/Overconfidence.html @@ -0,0 +1,29 @@ +{{ block title }}Task: Estimation Intervals{{ endblock }} +{{ block content }} +

+ Policy setting: you are advising a department on targets with uncertainty. Your intervals inform + risk and procurement decisions. Answer as if you were submitting to a real briefing. +

+

+ Enter a 90% confidence interval for each item so that the true value would fall inside your interval + about 9 times out of 10. Wider intervals are safer; aim for calibration rather than precision. +

+

+ What does “90% CI low/high” mean? +

+
    +
  • 90% CI low: your conservative lower bound — a number you think the true value is unlikely to be below.
  • +
  • 90% CI high: your conservative upper bound — a number you think the true value is unlikely to be above.
  • +
+

+ If you repeated this task many times for similar questions, about 9 out of 10 of your intervals should contain the true value. + It’s okay if your intervals are wide. Just make sure low ≤ high for each item. +

+
    +
  • US population in 2020 — units: millions
  • +
  • Total number of immigrants (non‑natives) currently residing in the US — units: millions
  • +
  • Share of US federal budget spent on Medicaid — units: percent (%)
  • +
+ {{ formfields }} + {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/Overplacement.html b/policy_survey_biases/Overplacement.html new file mode 100644 index 0000000000000000000000000000000000000000..a7958d0aacf4eb8feead39a8f380809a78987e25 --- /dev/null +++ b/policy_survey_biases/Overplacement.html @@ -0,0 +1,19 @@ +{{ block title }}Task: Comparative Self-Assessment{{ endblock }} +{{ block content }} +

+ Policy setting: your self‑assessment will be used by a policy lead to assign you to one of several + workstreams for an urgent brief (48‑hour turnaround) on a city congestion‑pricing proposal. Teams include: + (1) quantitative modeling of scenarios and distributional impacts; (2) evidence synthesis and options analysis; + and (3) stakeholder communications and decision briefings. Rate yourself as you would if these ratings determined + your role today. +

+

+ Please rate yourself relative to peers in your field on the following: + domain knowledge relevant to policy, quantitative reasoning for analysis, and communication for decision-making. +

+ {{ formfields }} +

+ Scale anchors: 1=much lower than average, 4=about average, 7=much higher than average. +

+ {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/Results.html b/policy_survey_biases/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..4f65f085f5a7757f26950a9826321a2df1f0f7b5 --- /dev/null +++ b/policy_survey_biases/Results.html @@ -0,0 +1,76 @@ +{{ block title }}Summary{{ endblock }} +{{ block content }} +

Thank you. Below is a short summary of your responses (for debrief). This exercise measured confidence calibration, + relative self‑assessment, potential confirmation effects, and availability influences on policy priorities.

+

Overconfidence recap with true values

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemYour 90% CITrue ValueInside CI?
US population (millions, 2020){{ player.oc1_low }} – {{ player.oc1_high }}{{ oc1_truth }}{{ if oc1_ok }}Yes{{ else }}No{{ endif }}
Immigrants residing in US (millions){{ player.oc2_low }} – {{ player.oc2_high }}{{ oc2_truth }}{{ if oc2_ok }}Yes{{ else }}No{{ endif }}
Federal budget spent on Medicaid (%){{ player.oc3_low }} – {{ player.oc3_high }}{{ oc3_truth }}%{{ if oc3_ok }}Yes{{ else }}No{{ endif }}
+ +

Personal recap

+
    +
  • Self-rating (domain knowledge vs. peers): {{ player.self_rank_policy_knowledge }}
  • + {{ if player.field_maybe_none('self_rank_quant_reasoning') }} +
  • Self-rating (quantitative reasoning vs. peers): {{ player.self_rank_quant_reasoning }}
  • + {{ endif }} + {{ if player.field_maybe_none('self_rank_comm_briefing') }} +
  • Self-rating (communication/briefing vs. peers): {{ player.self_rank_comm_briefing }}
  • + {{ endif }} +
  • Information timing condition: {{ player.order_a }}
  • +
  • Agreement (final): {{ player.agree_post }}{% if player.field_maybe_none('agree_pre') %} (initial: {{ player.agree_pre }}){% endif %}
  • +
  • Statements/Evidence order: {{ player.order_b }}
  • +
  • Support (overall): {{ player.support_policy }}
  • +
  • Topics/News order: {{ player.order_c }}
  • +
  • Support — Energy: {{ player.support_energy }}; Public health: {{ player.support_public_health }}
  • +
  • Priority — Energy: {{ player.priority_energy }}; Public health: {{ player.priority_public_health }}
  • +
+

Mock Aggregate Results (Illustrative)

+
    +
  • Average self-rating (vs. peers): 4.8 (vs. midpoint 4.0)
  • +
  • % of 90% CIs containing true value (US pop): ~65% (vs. target 90%)
  • +
  • Average agreement change (info-after vs. info-before): +0.5 points
  • +
  • Priority shift (recall vs. opinion first): Energy +0.3, Health -0.1
  • +
+

Notes

+
    +
  • Order effects (Module A): you were assigned “{{ player.order_a }}”. {% if player.field_maybe_none('agree_pre') %} + Your initial view was {{ player.agree_pre }} and your final view was {{ player.agree_post }}.{% else %} + Your final view after the information item was {{ player.agree_post }}.{% endif %}
  • +
  • Statements & evidence (Module B): your support was recorded {% if player.order_b == 'support_first' %}before{% else %}after{% endif %} the evaluation items.
  • +
  • Topics & news (Module C): you were assigned “{{ player.order_c }}”. Differences by order, if any, are common and informative.
  • +
+

Class discussion

+

In class, we will look at anonymized aggregates (e.g., mean views by order condition) to illustrate how timing and + task order can relate to responses. We will not label responses as “biased”; the goal is to understand patterns and + measurement.

+

For a neutral overview of cognitive biases, you can visit this resource.

+

Further reading will be provided in the course materials.

+ {{ next_button }} +{{ endblock }} diff --git a/policy_survey_biases/__init__.py b/policy_survey_biases/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..09490105f8bb257a9060a5fa551531408bc17880 --- /dev/null +++ b/policy_survey_biases/__init__.py @@ -0,0 +1,507 @@ +from __future__ import annotations +from otree.api import * + + +class C(BaseConstants): + NAME_IN_URL = 'policy_survey_biases' + PLAYERS_PER_GROUP = None + NUM_ROUNDS = 1 + # Truth values for overconfidence recap (approximate) + OC1_TRUE_MILLIONS = 331 # US population in 2020 (millions) + OC2_TRUE_MILLIONS = 46 # Total immigrants residing in US (millions) + OC3_TRUE_PERCENT = 10 # Share of US federal budget spent on Medicaid (%) + + +class Subsession(BaseSubsession): + def creating_session(self): + import random + players = self.get_players() + n = len(players) + if n == 0: + return + def assign_balanced(choices): + k = len(choices) + # Distribute as evenly as possible, then shuffle + base = n // k + rem = n % k + vals = [] + for i, choice in enumerate(choices): + count = base + (1 if i < rem else 0) + vals.extend([choice] * count) + random.shuffle(vals) + return vals + + # Balanced randomization for A, B, and C per session + orders_a = assign_balanced(['info_before', 'info_after']) + orders_b = assign_balanced(['support_first', 'eval_first']) + orders_c = assign_balanced(['recall_first', 'opinion_first']) + + for p, a, b, c in zip(players, orders_a, orders_b, orders_c): + if not p.order_a: + p.order_a = a + p.participant.vars['order_a'] = p.order_a + if not p.order_b: + p.order_b = b + p.participant.vars['order_b'] = p.order_b + if not p.field_maybe_none('order_c'): + p.order_c = c + p.participant.vars['order_c'] = p.order_c + + +def _debug_vars(player: Player, page: str, form_fields=None): + """Build a rich debug payload for templates and troubleshooting. + + Injected under the 'debug' key in vars_for_template across pages. + Safe to leave in production; nothing renders unless templates reference it. + """ + try: + cfg = dict(player.session.config) + except Exception: + cfg = {} + fields = list(form_fields or []) + values = {} + for name in fields: + try: + values[name] = player.field_maybe_none(name) + except Exception: + values[name] = getattr(player, name, None) + orders = dict( + a=player.field_maybe_none('order_a'), + b=player.field_maybe_none('order_b'), + c=player.field_maybe_none('order_c'), + ) + return dict( + debug=dict( + page=page, + app=C.NAME_IN_URL, + session_id=getattr(player.session, 'id', None), + session_config=cfg, + round_number=player.round_number, + participant=dict( + id_in_session=player.participant.id_in_session, + label=getattr(player.participant, 'label', None), + vars=dict(player.participant.vars), + ), + orders=orders, + form_fields=fields, + form_values=values, + truths=dict( + oc1=C.OC1_TRUE_MILLIONS, + oc2=C.OC2_TRUE_MILLIONS, + oc3=C.OC3_TRUE_PERCENT, + ), + ) + ) + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + # Overconfidence: 90% intervals (low/high) + oc1_low = models.IntegerField(label='US population in 2020 (millions) — 90% CI low') + oc1_high = models.IntegerField(label='US population in 2020 (millions) — 90% CI high') + + oc2_low = models.IntegerField(label='Total immigrants residing in the US (millions) — 90% CI low') + oc2_high = models.IntegerField(label='Total immigrants residing in the US (millions) — 90% CI high') + + oc3_low = models.IntegerField(label='US federal budget spent on Medicaid (%) — 90% CI low') + oc3_high = models.IntegerField(label='US federal budget spent on Medicaid (%) — 90% CI high') + + # Overplacement (self-rating vs. peers in same field) + self_rank_policy_knowledge = models.IntegerField( + label='Domain knowledge relevant to policy in my field (vs. peers)', + choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + ) + self_rank_quant_reasoning = models.IntegerField( + label='Quantitative reasoning for policy analysis (vs. peers)', + choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + blank=True, + ) + self_rank_comm_briefing = models.IntegerField( + label='Communication: writing, synthesis, and briefing for decisions (vs. peers)', + choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + blank=True, + ) + + # Module A: Information timing (randomized order) + order_a = models.StringField() + agree_pre = models.IntegerField( + label='“Rebate programs are cost‑effective for taxpayers.” Overall agreement (1=Strongly disagree … 7=Strongly agree)', choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + blank=True, + ) + agree_post = models.IntegerField( + label='“Rebate programs are cost‑effective for taxpayers.” Overall agreement (1=Strongly disagree … 7=Strongly agree)', choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + ) + snippet_truth = models.IntegerField( + label='Perceived accuracy of the information item (1=Not at all accurate … 7=Extremely accurate)', choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + blank=True, + ) + comp_choice = models.StringField( + label='According to the item, which reduction range was reported?', + choices=['2–4%', '5–10%', '15–20%'], + blank=True, + ) + + # Module B: Arguments & facts vs support (randomized order) + order_b = models.StringField() + support_policy = models.IntegerField( + label='Overall, how much do you support this policy in your city/state?', + choices=[1, 2, 3, 4, 5, 6, 7], + widget=widgets.RadioSelectHorizontal, + blank=True, + ) + # Arguments (persuasiveness) + arg1_strength = models.IntegerField(label='Peak-hour congestion and travel times are expected to decline.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + arg2_strength = models.IntegerField(label='Local air quality and public health are expected to improve.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + arg3_strength = models.IntegerField(label='Costs may place a disproportionate burden on lower-income drivers.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + arg4_strength = models.IntegerField(label='Nearby businesses may experience reduced customer traffic.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + # Facts (perceived accuracy) + fact1_truth = models.IntegerField(label='Evaluations in peer cities report 10–20% reductions in downtown traffic.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + fact2_truth = models.IntegerField(label='In most implementations, operating costs exceed program revenues.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + fact3_truth = models.IntegerField(label='Freight vehicles are exempt under all implementations.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + fact4_truth = models.IntegerField(label='Most programs include discounts or rebates for lower-income drivers.', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + + # Module C: Availability/salience (two topics; randomized order) + order_c = models.StringField() + # Recall block + recall_energy_text = models.LongStringField(label='Briefly describe a recent news item you saw about grid reliability or energy (1–2 sentences).', blank=True) + recency_energy = models.StringField( + label='How recent was it?', + choices=[ + 'today', + 'this week', + 'this month', + '1–3 months', + '3–6 months', + '6–12 months', + 'more than a year ago', + 'earlier', # kept for compatibility; can be retired later + "don’t recall", + ], + blank=True, + ) + recall_public_health_text = models.LongStringField(label='Briefly describe a recent news item you saw about public health preparedness (1–2 sentences).', blank=True) + recency_public_health = models.StringField( + label='How recent was it?', + choices=[ + 'today', + 'this week', + 'this month', + '1–3 months', + '3–6 months', + '6–12 months', + 'more than a year ago', + 'earlier', + "don’t recall", + ], + blank=True, + ) + # Opinion/priorities + support_energy = models.IntegerField(label='Support: investments in grid reliability and resilience', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + support_public_health = models.IntegerField(label='Support: investments in public health preparedness', choices=[1,2,3,4,5,6,7], widget=widgets.RadioSelectHorizontal, blank=True) + priority_energy = models.IntegerField(label='Priority this year: grid reliability and resilience', choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal) + priority_public_health = models.IntegerField(label='Priority this year: public health preparedness', choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal) + + +# PAGES +class Overconfidence(Page): + form_model = 'player' + form_fields = [ + 'oc1_low', 'oc1_high', + 'oc2_low', 'oc2_high', + 'oc3_low', 'oc3_high', + ] + + @staticmethod + def error_message(player: Player, values): + errors = {} + for i in (1, 2, 3): + lo = values.get(f'oc{i}_low') + hi = values.get(f'oc{i}_high') + if lo is not None and hi is not None and lo > hi: + errors[f'oc{i}_high'] = 'High must be greater than or equal to Low.' + return errors + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'Overconfidence', Overconfidence.form_fields) + + +class Overplacement(Page): + form_model = 'player' + form_fields = ['self_rank_policy_knowledge', 'self_rank_quant_reasoning', 'self_rank_comm_briefing'] + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'Overplacement', Overplacement.form_fields) + +def _ensure_order_a(player: Player): + if player.field_maybe_none('order_a') is None: + # Deterministic fallback: alternate by participant position + player.order_a = 'info_before' if (player.participant.id_in_session % 2 == 1) else 'info_after' + player.participant.vars['order_a'] = player.order_a + + +class ConfirmationA_InfoBefore(Page): + form_model = 'player' + form_fields = ['comp_choice', 'agree_post'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_a(player) + return player.order_a == 'info_before' + + @staticmethod + def error_message(player: Player, values): + errors = {} + if values.get('comp_choice') and values.get('comp_choice') != '5–10%': + errors['comp_choice'] = 'Please review the information item and try again.' + return errors + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ConfirmationA_InfoBefore', ConfirmationA_InfoBefore.form_fields) + + +class ConfirmationA_Pre(Page): + form_model = 'player' + form_fields = ['agree_pre'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_a(player) + return player.order_a == 'info_after' + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ConfirmationA_Pre', ConfirmationA_Pre.form_fields) + + +class ConfirmationA_Snippet(Page): + form_model = 'player' + form_fields = ['snippet_truth', 'comp_choice'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_a(player) + return player.order_a == 'info_after' + + @staticmethod + def error_message(player: Player, values): + errors = {} + if values.get('comp_choice') and values.get('comp_choice') != '5–10%': + errors['comp_choice'] = 'Please review the information item and try again.' + return errors + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ConfirmationA_Snippet', ConfirmationA_Snippet.form_fields) + + +class ConfirmationA_Post(Page): + form_model = 'player' + form_fields = ['agree_post'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_a(player) + return player.order_a == 'info_after' + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ConfirmationA_Post', ConfirmationA_Post.form_fields) + + +def _ensure_order_b(player: Player): + if player.field_maybe_none('order_b') is None: + player.order_b = 'support_first' if (player.participant.id_in_session % 2 == 1) else 'eval_first' + player.participant.vars['order_b'] = player.order_b + + +class ModuleB_Support(Page): + form_model = 'player' + form_fields = ['support_policy'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_b(player) + return player.order_b == 'support_first' + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleB_Support', ModuleB_Support.form_fields) + template_name = 'policy_survey_biases/ModuleB_Support.html' + + +class ModuleB_Eval(Page): + form_model = 'player' + form_fields = [ + 'arg1_strength', 'arg2_strength', 'arg3_strength', 'arg4_strength', + 'fact1_truth', 'fact2_truth', 'fact3_truth', 'fact4_truth', + ] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_b(player) + return True + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleB_Eval', ModuleB_Eval.form_fields) + template_name = 'policy_survey_biases/ModuleB_Eval.html' + + +class ModuleB_Support_After(Page): + form_model = 'player' + form_fields = ['support_policy'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_b(player) + return player.order_b == 'eval_first' + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleB_Support_After', ModuleB_Support_After.form_fields) + template_name = 'policy_survey_biases/ModuleB_Support.html' + + +def _ensure_order_c(player: Player): + if player.field_maybe_none('order_c') is None: + player.order_c = 'recall_first' if (player.participant.id_in_session % 2 == 1) else 'opinion_first' + player.participant.vars['order_c'] = player.order_c + + +class ModuleC_Opinion_First(Page): + form_model = 'player' + form_fields = ['support_energy', 'support_public_health', 'priority_energy', 'priority_public_health'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_c(player) + return player.order_c == 'opinion_first' + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleC_Opinion_First', ModuleC_Opinion_First.form_fields) + + template_name = 'policy_survey_biases/ModuleC_Opinion.html' + + +class ModuleC_Recall_First(Page): + form_model = 'player' + form_fields = ['recall_energy_text', 'recency_energy', 'recall_public_health_text', 'recency_public_health'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_c(player) + return player.order_c == 'recall_first' + + @staticmethod + def error_message(player: Player, values): + errs = {} + def too_short(s): + return s is None or len(s.strip()) < 20 + if too_short(values.get('recall_energy_text')): + errs['recall_energy_text'] = 'Please provide 1–2 sentences (at least ~20 characters).' + if too_short(values.get('recall_public_health_text')): + errs['recall_public_health_text'] = 'Please provide 1–2 sentences (at least ~20 characters).' + return errs + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleC_Recall_First', ModuleC_Recall_First.form_fields) + + template_name = 'policy_survey_biases/ModuleC_Recall.html' + + +class ModuleC_Recall_After(Page): + form_model = 'player' + form_fields = ['recall_energy_text', 'recency_energy', 'recall_public_health_text', 'recency_public_health'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_c(player) + return player.order_c == 'opinion_first' + + @staticmethod + def error_message(player: Player, values): + errs = {} + def too_short(s): + return s is None or len(s.strip()) < 20 + if too_short(values.get('recall_energy_text')): + errs['recall_energy_text'] = 'Please provide 1–2 sentences (at least ~20 characters).' + if too_short(values.get('recall_public_health_text')): + errs['recall_public_health_text'] = 'Please provide 1–2 sentences (at least ~20 characters).' + return errs + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleC_Recall_After', ModuleC_Recall_After.form_fields) + + template_name = 'policy_survey_biases/ModuleC_Recall.html' + + +class ModuleC_Opinion_After(Page): + form_model = 'player' + form_fields = ['support_energy', 'support_public_health', 'priority_energy', 'priority_public_health'] + + @staticmethod + def is_displayed(player: Player): + _ensure_order_c(player) + return player.order_c == 'recall_first' + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'ModuleC_Opinion_After', ModuleC_Opinion_After.form_fields) + + template_name = 'policy_survey_biases/ModuleC_Opinion.html' + + +class Availability(Page): + form_model = 'player' + form_fields = ['recall_event', 'priority_energy', 'priority_public_health'] + + @staticmethod + def vars_for_template(player: Player): + return _debug_vars(player, 'Availability', Availability.form_fields) + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + def within(low, high, truth): + return (low is not None and high is not None) and (low <= truth <= high) + base = dict( + oc1_truth=C.OC1_TRUE_MILLIONS, + oc1_ok=within(player.oc1_low, player.oc1_high, C.OC1_TRUE_MILLIONS), + oc2_truth=C.OC2_TRUE_MILLIONS, + oc2_ok=within(player.oc2_low, player.oc2_high, C.OC2_TRUE_MILLIONS), + oc3_truth=C.OC3_TRUE_PERCENT, + oc3_ok=within(player.oc3_low, player.oc3_high, C.OC3_TRUE_PERCENT), + ) + base.update(_debug_vars(player, 'Results')) + return base + + +page_sequence = [ + Overconfidence, + Overplacement, + ConfirmationA_InfoBefore, + ConfirmationA_Pre, + ConfirmationA_Snippet, + ConfirmationA_Post, + ModuleB_Support, + ModuleB_Eval, + ModuleB_Support_After, + ModuleC_Recall_First, + ModuleC_Opinion_First, + ModuleC_Recall_After, + ModuleC_Opinion_After, + Results, +] diff --git a/policy_survey_biases/tests.py b/policy_survey_biases/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..61b3290cda27aea8447d8920aaf2f78003ef00da --- /dev/null +++ b/policy_survey_biases/tests.py @@ -0,0 +1,114 @@ +from otree.api import Bot, Submission, SubmissionMustFail +from . import ( + Overconfidence, + Overplacement, + ConfirmationA_InfoBefore, + ConfirmationA_Pre, + ConfirmationA_Snippet, + ConfirmationA_Post, + ModuleB_Support, + ModuleB_Eval, + ModuleB_Support_After, + ModuleC_Opinion_First, + ModuleC_Recall_First, + ModuleC_Recall_After, + ModuleC_Opinion_After, + Availability, + Results, +) + + +class PlayerBot(Bot): + def play_round(self): + # Sanity-check that randomization produced variation whenever N>1 + if self.subsession.session.num_participants and self.subsession.session.num_participants > 1: + if self.player.id_in_group == 1: + players = self.subsession.get_players() + # Fall back to deterministic alternation if not set (mirrors _ensure_order_* logic) + def a_of(p): + return p.field_maybe_none('order_a') or ('info_before' if (p.participant.id_in_session % 2 == 1) else 'info_after') + def b_of(p): + return p.field_maybe_none('order_b') or ('support_first' if (p.participant.id_in_session % 2 == 1) else 'eval_first') + def c_of(p): + return p.field_maybe_none('order_c') or ('recall_first' if (p.participant.id_in_session % 2 == 1) else 'opinion_first') + orders_a = {a_of(p) for p in players} + orders_b = {b_of(p) for p in players} + orders_c = {c_of(p) for p in players} + assert len(orders_a) > 1, f"Order A not varied: {orders_a}" + assert len(orders_b) > 1, f"Order B not varied: {orders_b}" + assert len(orders_c) > 1, f"Order C not varied: {orders_c}" + # First, demonstrate validation: high must be >= low + yield SubmissionMustFail(Overconfidence, dict( + oc1_low=400, oc1_high=200, # invalid (low > high) + oc2_low=300, oc2_high=500, + oc3_low=300000, oc3_high=500000, + )) + # Then submit valid ranges + yield Submission(Overconfidence, dict( + oc1_low=200, oc1_high=400, + oc2_low=300, oc2_high=500, + oc3_low=300000, oc3_high=500000, + )) + yield Submission(Overplacement, dict(self_rank_policy_knowledge=4)) + # Module A branch: submit according to assigned order + if self.player.order_a == 'info_before': + # Info shown first, then agreement; include correct comp answer + yield Submission(ConfirmationA_InfoBefore, dict( + comp_choice='5–10%', + agree_post=4, + )) + else: + # info_after branch: pre → snippet (with comp + truth) → post + yield Submission(ConfirmationA_Pre, dict(agree_pre=3)) + # Demonstrate validation: wrong comp_choice must fail + yield SubmissionMustFail(ConfirmationA_Snippet, dict( + snippet_truth=5, + comp_choice='2–4%', + )) + yield Submission(ConfirmationA_Snippet, dict( + snippet_truth=6, + comp_choice='5–10%', + )) + yield Submission(ConfirmationA_Post, dict(agree_post=4)) + # Module B branch: support-first vs eval-first + if self.player.order_b == 'support_first': + yield Submission(ModuleB_Support, dict(support_policy=5)) + yield Submission(ModuleB_Eval, dict( + arg1_strength=5, arg2_strength=5, arg3_strength=4, arg4_strength=3, + fact1_truth=5, fact2_truth=6, fact3_truth=2, fact4_truth=5, + )) + else: + yield Submission(ModuleB_Eval, dict( + arg1_strength=5, arg2_strength=5, arg3_strength=4, arg4_strength=3, + fact1_truth=5, fact2_truth=6, fact3_truth=2, fact4_truth=5, + )) + yield Submission(ModuleB_Support_After, dict(support_policy=5)) + # Module C: availability/salience order + if self.player.order_c == 'recall_first': + # Negative case: too-short recall should fail + yield SubmissionMustFail(ModuleC_Recall_First, dict( + recall_energy_text='Too short', recency_energy='today', + recall_public_health_text='Short', recency_public_health='this week', + )) + yield Submission(ModuleC_Recall_First, dict( + recall_energy_text='Saw an article about grid investments to reduce outages.', recency_energy='this week', + recall_public_health_text='Read a brief on local preparedness drills for emergencies.', recency_public_health='this month', + )) + yield Submission(ModuleC_Opinion_After, dict( + support_energy=6, support_public_health=5, + priority_energy=6, priority_public_health=5, + )) + else: + yield Submission(ModuleC_Opinion_First, dict( + support_energy=6, support_public_health=5, + priority_energy=6, priority_public_health=5, + )) + yield SubmissionMustFail(ModuleC_Recall_After, dict( + recall_energy_text='Short', recency_energy='today', + recall_public_health_text='', recency_public_health='earlier', + )) + yield Submission(ModuleC_Recall_After, dict( + recall_energy_text='Saw an article about grid investments to reduce outages.', recency_energy='this week', + recall_public_health_text='Read a brief on local preparedness drills for emergencies.', recency_public_health='this month', + )) + yield Results diff --git a/policy_trust_framed/Introduction.html b/policy_trust_framed/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..b7c3370a8e0431d4a131a7002bfd860410dba110 --- /dev/null +++ b/policy_trust_framed/Introduction.html @@ -0,0 +1,21 @@ +{{ block title }}Introduction — Game 2 of 3 (Policy/Bias){{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ +

+ You and another participant are taking part in a pilot program for a Small Business Investment Fund. + One of you is a "Community Investor," the other a "Local Entrepreneur." + The Investor receives a small grant and can invest in the Entrepreneur's project. + The investment is matched 3-to-1 by a city fund. The Entrepreneur can then share profits back with the Investor. +

+

+ Treat this as a real investment decision: points convert to a bonus at the end of the study. Read the rules below + carefully; both of you see the same instructions and make choices privately. +

+ + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/policy_trust_framed/Results.html b/policy_trust_framed/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..f283fca13e319fa7706ac1f93aff306c866b42d0 --- /dev/null +++ b/policy_trust_framed/Results.html @@ -0,0 +1,32 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + + {{ if player.id_in_group == 1 }} +

+ As the Investor, you chose to invest {{ group.sent_amount }}. + The Entrepreneur returned {{ group.sent_back_amount }}. +

+

+ You started with {{ C.ENDOWMENT }} in capital, invested {{ group.sent_amount }}, + and received a return of {{ group.sent_back_amount }}. + Your final payoff is: + {{ C.ENDOWMENT }} - {{ group.sent_amount }} + {{ group.sent_back_amount }} = {{ player.payoff }} +

+ {{ else }} +

+ As the Entrepreneur, you received an investment of {{ group.sent_amount }} from the Investor. + This was matched by the city fund, giving you {{ tripled_amount }} in capital. + You chose to return {{ group.sent_back_amount }} to the Investor. +

+

+ You started with {{ tripled_amount }} in capital and returned {{ group.sent_back_amount }}. + Your final payoff is: + {{ tripled_amount }} - {{ group.sent_back_amount }} = {{ player.payoff }} +

+ {{ endif }} + +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/policy_trust_framed/Send.html b/policy_trust_framed/Send.html new file mode 100644 index 0000000000000000000000000000000000000000..0dc93e9733511507c62ebdf006862efd9dcfa892 --- /dev/null +++ b/policy_trust_framed/Send.html @@ -0,0 +1,21 @@ +{{ block title }}Your Investment Decision{{ endblock }} +{{ block content }} + +

+ Your investment capital: {{ C.ENDOWMENT }} points. + You are the Community Investor (Participant A). You may invest any amount from 0 to {{ C.ENDOWMENT }} + in the Local Entrepreneur's project. +

+

+ Your investment will be matched 3-to-1 by the city's development fund. + Choose based on your own preferences; there is no right or wrong answer. +

+ + {{ formfields }} +

+ {{ next_button }} +

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/policy_trust_framed/SendBack.html b/policy_trust_framed/SendBack.html new file mode 100644 index 0000000000000000000000000000000000000000..19d4df26929e4cedd8c1a904a80730092c073e56 --- /dev/null +++ b/policy_trust_framed/SendBack.html @@ -0,0 +1,16 @@ +{{ block title }}Your Return Decision{{ endblock }} +{{ block content }} +

+ You are the Local Entrepreneur (Participant B). The Investor sent {{ group.sent_amount }}, which was + tripled by the city fund to {{ tripled_amount }}. This is your project's starting capital. +

+

+ After the project, you can share some of the proceeds back with the Community Investor as a return on their investment. + Decide freely how much to return. +

+ + {{ formfields }} +

{{ next_button }}

+ {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/policy_trust_framed/__init__.py b/policy_trust_framed/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..aef24e3956894a01bc03499064603ac902bd7ae6 --- /dev/null +++ b/policy_trust_framed/__init__.py @@ -0,0 +1,121 @@ +from otree.api import * + + + + +doc = """ +This is a standard 2-player trust game where the amount sent by player 1 gets +tripled. The trust game was first proposed by + + Berg, Dickhaut, and McCabe (1995) +. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'policy_trust_framed' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + # Initial amount allocated to each player + ENDOWMENT = cu(100) + MULTIPLIER = 3 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + sent_amount = models.CurrencyField( + min=0, + max=C.ENDOWMENT, + doc="""Amount invested by Investor (P1)""", + label="How much will you invest in the Entrepreneur's project?", + ) + sent_back_amount = models.CurrencyField( + doc="""Amount returned by Entrepreneur (P2)""", + min=cu(0), + label="How much of the proceeds will you share back with the Investor?", + ) + + +class Player(BasePlayer): + pass + + +# FUNCTIONS +def sent_back_amount_max(group: Group): + return group.sent_amount * C.MULTIPLIER + + +def set_payoffs(group: Group): + p1 = group.get_player_by_id(1) + p2 = group.get_player_by_id(2) + p1.payoff = C.ENDOWMENT - group.sent_amount + group.sent_back_amount + p2.payoff = group.sent_amount * C.MULTIPLIER - group.sent_back_amount + + +# PAGES +class Introduction(Page): + pass + + +class Send(Page): + """This page is only for P1 + P1 sends amount (all, some, or none) to P2 + This amount is tripled by experimenter, + i.e if sent amount by P1 is 5, amount received by P2 is 15""" + + form_model = 'group' + form_fields = ['sent_amount'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 1 + + +class SendBackWaitPage(WaitPage): + pass + + +class SendBack(Page): + """This page is only for P2 + P2 sends back some amount (of the tripled amount received) to P1""" + + form_model = 'group' + form_fields = ['sent_back_amount'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 2 + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + tripled_amount = group.sent_amount * C.MULTIPLIER + return dict(tripled_amount=tripled_amount) + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + """This page displays the earnings of each player""" + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(tripled_amount=group.sent_amount * C.MULTIPLIER) + + +page_sequence = [ + Introduction, + Send, + SendBackWaitPage, + SendBack, + ResultsWaitPage, + Results, +] diff --git a/policy_trust_framed/instructions.html b/policy_trust_framed/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..d34fa4f892e306bfaff7fc058f19482166593068 --- /dev/null +++ b/policy_trust_framed/instructions.html @@ -0,0 +1,21 @@ + +
+
+ +

Instructions

+

+ Policy setting: A local economic development agency is running a pilot micro-investment program. + One participant is a "Community Investor" (Participant A), the other is a "Local Entrepreneur" (Participant B). +

+

+ You are randomly and anonymously paired. The Investor (A) starts with a grant of {{ C.ENDOWMENT }} points. + The Entrepreneur (B) starts with 0. Roles are assigned at random and shown before any decision. +

+

+ The Investor (A) may invest any amount from 0 to {{ C.ENDOWMENT }} in the Entrepreneur's (B's) project. + The development agency matches the investment at a {{ C.MULTIPLIER }}-to-1 rate. After receiving the multiplied funds, + the Entrepreneur (B) may share any portion of the proceeds back with the Investor (A) as a return on investment. + All points convert to a bonus at the end of the study. +

+
+
diff --git a/policy_trust_framed/tests.py b/policy_trust_framed/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..b8f16d89fce16cf00e406f48b460a00ffbdd4d01 --- /dev/null +++ b/policy_trust_framed/tests.py @@ -0,0 +1,21 @@ +from otree.api import Bot, Submission, expect, cu +from . import Introduction, Send, SendBack, Results + + +class PlayerBot(Bot): + def play_round(self): + yield Introduction + if self.player.id_in_group == 1: + # P1 sends 10 + yield Submission(Send, dict(sent_amount=cu(10))) + else: + # P2 returns 15 (half of tripled 30) + yield Submission(SendBack, dict(sent_back_amount=cu(15))) + + if self.player.id_in_group == 2: + # After both acted, verify payoffs on results + p1 = self.group.get_player_by_id(1) + p2 = self.group.get_player_by_id(2) + expect(p1.payoff, cu(100) - cu(10) + cu(15)) + expect(p2.payoff, cu(10) * 3 - cu(15)) + yield Results diff --git a/prisoner/Decision.html b/prisoner/Decision.html new file mode 100644 index 0000000000000000000000000000000000000000..5a090b9f01212e4224d9d2fe96d0e2e25088d850 --- /dev/null +++ b/prisoner/Decision.html @@ -0,0 +1,30 @@ +{{ block title }}Your Choice{{ endblock }} +{{ block content }} + +
+ + + + + + + + + + + + + + + + + + + + +
The Other Participant
Choice AChoice B
You{{C.PAYOFF_B}}, {{C.PAYOFF_B}}{{ C.PAYOFF_D }}, {{C.PAYOFF_A}}
{{C.PAYOFF_A}}, {{ C.PAYOFF_D }}{{C.PAYOFF_C}}, {{C.PAYOFF_C}}
+
+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/prisoner/Introduction.html b/prisoner/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..142218075c4147f7c6e63d4c07c436fe1771a76f --- /dev/null +++ b/prisoner/Introduction.html @@ -0,0 +1,10 @@ +{{ block title }}Introduction — Game 1 of 3{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/prisoner/Results.html b/prisoner/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..b43a0fe84e3df31767dd234970fa4cda17a403d7 --- /dev/null +++ b/prisoner/Results.html @@ -0,0 +1,21 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + +

+ {{ if same_choice }} + Both of you chose to {{ my_decision }}. + {{ else }} + You chose to {{ my_decision }} and the other participant chose to {{ opponent_decision }}. + {{ endif }} +

+ +

+ As a result, you earned {{ player.payoff }}. +

+ +
+

Next up: Trust Game. Click Next to continue.

+ + {{ next_button }} + +{{ endblock }} diff --git a/prisoner/__init__.py b/prisoner/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2e71616f84200c87623e521330ff8c280b58d63b --- /dev/null +++ b/prisoner/__init__.py @@ -0,0 +1,87 @@ +from otree.api import * + + +doc = """ +This is a one-shot "Prisoner's Dilemma". Two players are asked separately +whether they want to cooperate or defect. Their choices directly determine the +payoffs. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'prisoner' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + PAYOFF_A = cu(300) + PAYOFF_B = cu(200) + PAYOFF_C = cu(100) + PAYOFF_D = cu(0) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + cooperate = models.BooleanField( + choices=[[True, 'Choice A'], [False, 'Choice B']], + doc="""This player's decision (labels neutralized)""", + widget=widgets.RadioSelect, + ) + + +# FUNCTIONS +def set_payoffs(group: Group): + for p in group.get_players(): + set_payoff(p) + + +def other_player(player: Player): + return player.get_others_in_group()[0] + + +def set_payoff(player: Player): + payoff_matrix = { + (False, True): C.PAYOFF_A, + (True, True): C.PAYOFF_B, + (False, False): C.PAYOFF_C, + (True, False): C.PAYOFF_D, + } + other = other_player(player) + player.payoff = payoff_matrix[(player.cooperate, other.cooperate)] + + +# PAGES +class Introduction(Page): + @staticmethod + def get_timeout_seconds(player: Player): + # Allow session config override; default to 100 seconds + return player.session.config.get('prisoner_intro_timeout', 100) + + +class Decision(Page): + form_model = 'player' + form_fields = ['cooperate'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + opponent = other_player(player) + return dict( + opponent=opponent, + same_choice=player.cooperate == opponent.cooperate, + my_decision=player.field_display('cooperate'), + opponent_decision=opponent.field_display('cooperate'), + ) + + +page_sequence = [Introduction, Decision, ResultsWaitPage, Results] diff --git a/prisoner/instructions.html b/prisoner/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..43bb804396417d77d6117bf4b54819ce97d8d8c2 --- /dev/null +++ b/prisoner/instructions.html @@ -0,0 +1,39 @@ +
+
+ +

Prisoner’s Dilemma — Rules

+
    +
  • You are randomly paired; choices are made simultaneously and privately.
  • +
  • Choose between Choice A and Choice B.
  • +
  • Payoffs depend on both choices according to the matrix below.
  • +
  • One shot (no repeated rounds in this demo).
  • +
+

In each cell, the amount to the left is your payoff; to the right is the other participant’s.

+

In each cell, the amount to the left is the payoff for + you and to the right for the other participant.

+ + + + + + + + + + + + + + + + + + + + + +
The Other Participant
Choice AChoice B
YouChoice A{{ C.PAYOFF_B }}, {{ C.PAYOFF_B }}{{ C.PAYOFF_D }}, {{ C.PAYOFF_A }}
Choice B{{ C.PAYOFF_A }}, {{ C.PAYOFF_D }}{{ C.PAYOFF_C }}, {{ C.PAYOFF_C }}
+ +
+
diff --git a/prisoner/tests.py b/prisoner/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..9769bc18c38e981a13b7ede696085147727f452d --- /dev/null +++ b/prisoner/tests.py @@ -0,0 +1,14 @@ +from otree.api import Bot, Submission +from . import Introduction, Decision, Results + + +class PlayerBot(Bot): + def play_round(self): + # Simulate a timeout on the intro page to exercise timeout handling + yield Submission(Introduction, {}, timeout_happened=True) + # Player 1 cooperates, Player 2 defects to cover both branches + if self.player.id_in_group == 1: + yield Submission(Decision, {'cooperate': True}) + else: + yield Submission(Decision, {'cooperate': False}) + yield Results diff --git a/public_goods_simple/Contribute.html b/public_goods_simple/Contribute.html new file mode 100644 index 0000000000000000000000000000000000000000..9e25b77233e0fe3762e5687e24717f3839272baf --- /dev/null +++ b/public_goods_simple/Contribute.html @@ -0,0 +1,21 @@ +{{ extends "global/Page.html" }} +{{ block title }}Contribute{{ endblock }} +{{ block content }} + +

+ Your endowment: {{ C.ENDOWMENT }} points. + Group size: {{ C.PLAYERS_PER_GROUP }}. + Efficiency factor (multiplier): {{ C.MULTIPLIER }}. +

+

+ Enter how many points you wish to contribute to the shared fund (0–{{ C.ENDOWMENT }}). + You keep your unspent points and also receive an equal share of the multiplied fund. +

+ + {{ formfields }} + + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/public_goods_simple/Introduction.html b/public_goods_simple/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..76387e84d9f8f0ab1fbc2a97274f811635550495 --- /dev/null +++ b/public_goods_simple/Introduction.html @@ -0,0 +1,14 @@ +{{ block title }}Public Goods — Introduction (Game 3 of 3){{ endblock }} +{{ block content }} +

Note: Points convert to a bonus at the end of the study.

+
    +
  • Group size: {{ C.PLAYERS_PER_GROUP }}. Each participant receives an endowment of {{ C.ENDOWMENT }}.
  • +
  • You may contribute any amount (0–{{ C.ENDOWMENT }}) to a shared fund.
  • +
  • The fund is multiplied by {{ C.MULTIPLIER }} and then split equally among group members.
  • +
  • Your payoff = ({{ C.ENDOWMENT }} − your contribution) + your share of the multiplied fund.
  • +
  • One shot (no repeats in this demo). Decisions are private.
  • +
+

Example: if each of 3 players contributes 20, the total is 60. Multiplied by the efficiency factor and split equally, each participant receives an equal share from the fund. Your payoff equals your kept amount plus your share.

+

When ready, click Next to decide how much to contribute.

+ {{ next_button }} +{{ endblock }} diff --git a/public_goods_simple/Results.html b/public_goods_simple/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..d28637eb93a3d01145f2b8b9e4c4a899a354486c --- /dev/null +++ b/public_goods_simple/Results.html @@ -0,0 +1,14 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + +

+ You started with an endowment of {{ C.ENDOWMENT }}, + of which you contributed {{ player.contribution }}. + Your group contributed {{ group.total_contribution }}, + resulting in an individual share of {{ group.individual_share }}. + Your profit is therefore {{ player.payoff }}. +

+ + {{ next_button }} + +{{ endblock }} diff --git a/public_goods_simple/__init__.py b/public_goods_simple/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..528711e34448b797c6aaad2905f6e282e162695b --- /dev/null +++ b/public_goods_simple/__init__.py @@ -0,0 +1,58 @@ +from otree.api import * + + + +class C(BaseConstants): + NAME_IN_URL = 'public_goods_simple' + PLAYERS_PER_GROUP = 3 + NUM_ROUNDS = 1 + ENDOWMENT = cu(100) + MULTIPLIER = 1.8 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + total_contribution = models.CurrencyField() + individual_share = models.CurrencyField() + + +class Player(BasePlayer): + contribution = models.CurrencyField( + min=0, max=C.ENDOWMENT, label="How much will you contribute?" + ) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + contributions = [p.contribution for p in players] + group.total_contribution = sum(contributions) + group.individual_share = ( + group.total_contribution * C.MULTIPLIER / C.PLAYERS_PER_GROUP + ) + for p in players: + p.payoff = C.ENDOWMENT - p.contribution + group.individual_share + + +# PAGES +class Introduction(Page): + pass + + +class Contribute(Page): + form_model = 'player' + form_fields = ['contribution'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + pass + + +page_sequence = [Introduction, Contribute, ResultsWaitPage, Results] diff --git a/public_goods_simple/instructions.html b/public_goods_simple/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..4a290bfe228f56f3fdee11e124bbeaea06cbb1bc --- /dev/null +++ b/public_goods_simple/instructions.html @@ -0,0 +1,13 @@ +
+
+

Public Goods — Rules

+
    +
  • Group size: {{ C.PLAYERS_PER_GROUP }}. Each participant receives {{ C.ENDOWMENT }} points.
  • +
  • You may contribute any amount (0–{{ C.ENDOWMENT }}) to the shared fund.
  • +
  • The total fund is multiplied by {{ C.MULTIPLIER }} and then split equally among all group members.
  • +
  • Your payoff = ({{ C.ENDOWMENT }} − your contribution) + your equal share of the multiplied fund.
  • +
  • Decisions are private and this demo has a single round.
  • +
+

Reminder: Contributing increases the group total (and everyone’s share), while keeping points increases your private amount.

+
+
diff --git a/public_goods_simple/tests.py b/public_goods_simple/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..14a02aad4bbad45102191601138ed41b47a41822 --- /dev/null +++ b/public_goods_simple/tests.py @@ -0,0 +1,11 @@ +from otree.api import Bot, Submission, cu +from . import Introduction, Contribute, Results + + +class PlayerBot(Bot): + def play_round(self): + yield Introduction + contribs = {1: cu(10), 2: cu(20), 3: cu(30)} + yield Submission(Contribute, dict(contribution=contribs[self.player.id_in_group])) + yield Results + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..b6bb4024627f8263a3ddc8bc8fde268679e5518c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +otree==5.11.4 +psycopg2-binary==2.9.10 +sentry-sdk==0.7.9 diff --git a/sequence_welcome/Welcome.html b/sequence_welcome/Welcome.html new file mode 100644 index 0000000000000000000000000000000000000000..029367c41325784bc2c52d98e67f7b2c1c14e346 --- /dev/null +++ b/sequence_welcome/Welcome.html @@ -0,0 +1,20 @@ +{{ block title }}Welcome to the Behavioral Games Demo{{ endblock }} +{{ block content }} +

What to expect

+

+ You will play three short games that illustrate core ideas in behavioral public policy: +

+
    +
  1. Prisoner’s Dilemma — cooperation vs. self-interest
  2. +
  3. Trust Game (simple) — reciprocity and fair returns
  4. +
  5. Public Goods — contributions to a shared fund
  6. +
+

+ There are no right or wrong answers. Decide independently. After each game we will review results together. +

+

+ When you are ready, click Next to begin. +

+ {{ next_button }} +{{ endblock }} + diff --git a/sequence_welcome/__init__.py b/sequence_welcome/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0933bcad3504e03c7e5a3d7ed460eefe76b9bbb6 --- /dev/null +++ b/sequence_welcome/__init__.py @@ -0,0 +1,27 @@ +from otree.api import * + + +class C(BaseConstants): + NAME_IN_URL = 'sequence_welcome' + PLAYERS_PER_GROUP = None + NUM_ROUNDS = 1 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + pass + + +class Welcome(Page): + pass + + +page_sequence = [Welcome] + diff --git a/sequence_welcome/tests.py b/sequence_welcome/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..e5e7de2f5eb72506e9fd0cc66562f300621283b0 --- /dev/null +++ b/sequence_welcome/tests.py @@ -0,0 +1,8 @@ +from otree.api import Bot +from . import Welcome + + +class PlayerBot(Bot): + def play_round(self): + yield Welcome + diff --git a/settings.py b/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..01f2d3c23f22a96dcb1757d09a3925e92cc4e5ca --- /dev/null +++ b/settings.py @@ -0,0 +1,97 @@ +from os import environ + + +SESSION_CONFIGS = [ + # Built-in simple demos + dict( + name='guess_two_thirds', + display_name="Guess 2/3 of the Average", + app_sequence=['guess_two_thirds', 'payment_info'], + num_demo_participants=3, + ), + dict( + name='survey', app_sequence=['survey', 'payment_info'], num_demo_participants=1 + ), + + # Class demo sequences (approved scaffold) + dict( + name='classic_baseline', + display_name='Demo: Classical Baseline', + app_sequence=['sequence_welcome', 'prisoner', 'trust_simple', 'public_goods_simple', 'payment_info'], + num_demo_participants=6, # even; multiple of 3 recommended for public goods + # Optional: override prisoner.Introduction timeout (seconds) + # prisoner_intro_timeout=100, + doc='Welcome → PD → Trust (simple) → Public Goods (groups of 3)' + ), + dict( + name='policy_nudges', + display_name='Demo: Policy Framing Tweaks', + app_sequence=['policy_dictator_norms', 'policy_trust_framed', 'policy_public_goods_defaults', 'payment_info'], + num_demo_participants=6, # multiple of 6 for 2/2/3 grouping + doc='Instruction framing and light norm/default cues; no logic changes' + ), + dict( + name='guessing_game_demo', + display_name='Demo: Guessing Game with Reference Point', + app_sequence=['policy_guess_anchoring', 'payment_info'], + num_demo_participants=3, + doc='Guess 2/3 with a randomly assigned reference point; flexible group size' + ), + dict( + name='survey_biases', + display_name='Demo: Survey Tasks', + app_sequence=['policy_survey_biases', 'payment_info'], + num_demo_participants=1, + doc='Estimation intervals, self-assessment, and information-based survey items.' + ), + dict( + name='survey_biases_full', + display_name='Survey: Estimation, Information, and Recall', + app_sequence=['policy_survey_biases', 'payment_info'], + num_demo_participants=1, + doc='One-player survey combining estimation, information, and recall tasks.' + ), +] + +# if you set a property in SESSION_CONFIG_DEFAULTS, it will be inherited by all configs +# in SESSION_CONFIGS, except those that explicitly override it. +# the session config can be accessed from methods in your apps as self.session.config, +# e.g. self.session.config['participation_fee'] + +SESSION_CONFIG_DEFAULTS = dict( + real_world_currency_per_point=1.00, participation_fee=0.00, doc="" +) + +PARTICIPANT_FIELDS = ['show_anchor', 'anchor_value'] +SESSION_FIELDS = [] + +# ISO-639 code +# for example: de, fr, ja, ko, zh-hans +LANGUAGE_CODE = 'en' + +# e.g. EUR, GBP, CNY, JPY +REAL_WORLD_CURRENCY_CODE = 'USD' +USE_POINTS = True + +ROOMS = [ + dict( + name='econ101', + display_name='Econ 101 class', + participant_label_file='_rooms/econ101.txt', + ), + dict(name='live_demo', display_name='Room for live demo (no participant labels)'), + dict(name='lab_demo', display_name='Behavioral Policy Lab Demo', participant_label_file='_rooms/lab_demo.txt'), +] + +ADMIN_USERNAME = 'admin' +# for security, best to set admin password in an environment variable +ADMIN_PASSWORD = environ.get('OTREE_ADMIN_PASSWORD') + +DEMO_PAGE_INTRO_HTML = """ +Here are some oTree games. +""" + + +SECRET_KEY = '5788694860356' + +INSTALLED_APPS = ['otree'] diff --git a/survey/CognitiveReflectionTest.html b/survey/CognitiveReflectionTest.html new file mode 100644 index 0000000000000000000000000000000000000000..e6d0e42e7611f6322a8bb3e1b72444e333fb8d36 --- /dev/null +++ b/survey/CognitiveReflectionTest.html @@ -0,0 +1,12 @@ +{{ block title }}Survey{{ endblock }} +{{ block content }} + +

+ Please answer the following questions. +

+ + {{ formfields }} + + {{ next_button }} + +{{ endblock }} diff --git a/survey/Demographics.html b/survey/Demographics.html new file mode 100644 index 0000000000000000000000000000000000000000..e6d0e42e7611f6322a8bb3e1b72444e333fb8d36 --- /dev/null +++ b/survey/Demographics.html @@ -0,0 +1,12 @@ +{{ block title }}Survey{{ endblock }} +{{ block content }} + +

+ Please answer the following questions. +

+ + {{ formfields }} + + {{ next_button }} + +{{ endblock }} diff --git a/survey/__init__.py b/survey/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7521819e7e0239e369b1cc632402ddc2d3f373bb --- /dev/null +++ b/survey/__init__.py @@ -0,0 +1,59 @@ +from otree.api import * + + +class C(BaseConstants): + NAME_IN_URL = 'survey' + PLAYERS_PER_GROUP = None + NUM_ROUNDS = 1 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + age = models.IntegerField(label='What is your age?', min=13, max=125) + gender = models.StringField( + choices=[['Male', 'Male'], ['Female', 'Female']], + label='What is your gender?', + widget=widgets.RadioSelect, + ) + crt_bat = models.IntegerField( + label=''' + A bat and a ball cost 22 dollars in total. + The bat costs 20 dollars more than the ball. + How many dollars does the ball cost?''' + ) + crt_widget = models.IntegerField( + label=''' + If it takes 5 machines 5 minutes to make 5 widgets, + how many minutes would it take 100 machines to make 100 widgets? + ''' + ) + crt_lake = models.IntegerField( + label=''' + In a lake, there is a patch of lily pads. + Every day, the patch doubles in size. + If it takes 48 days for the patch to cover the entire lake, + how many days would it take for the patch to cover half of the lake? + ''' + ) + + +# FUNCTIONS +# PAGES +class Demographics(Page): + form_model = 'player' + form_fields = ['age', 'gender'] + + +class CognitiveReflectionTest(Page): + form_model = 'player' + form_fields = ['crt_bat', 'crt_widget', 'crt_lake'] + + +page_sequence = [Demographics, CognitiveReflectionTest] diff --git a/tests/test_anchoring_content_and_e2e.py b/tests/test_anchoring_content_and_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..2496b37e3180efaa1987e3f102c0f72e3e39647f --- /dev/null +++ b/tests/test_anchoring_content_and_e2e.py @@ -0,0 +1,39 @@ +import os +import subprocess +from pathlib import Path +import time + + +PROJECT_DIR = Path(__file__).resolve().parents[1] +OTREE = PROJECT_DIR.parents[1] / 'venv' / 'bin' / 'otree' + + +def run_otree_test_with_n(session: str, n: int): + env = os.environ.copy() + db_name = f"test_db_{session}_{n}_{int(time.time()*1000)}.sqlite3" + env['OTREE_DATABASE_URL'] = f"sqlite:///{db_name}" + proc = subprocess.run( + [str(OTREE), 'test', session, str(n)], + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + if proc.returncode != 0: + print(proc.stdout) + assert proc.returncode == 0 + + +def test_guessing_game_intro_and_results_copy(): + intro = (PROJECT_DIR / 'policy_guess_anchoring' / 'Introduction.html').read_text(encoding='utf-8') + intro_ws = ' '.join(intro.split()) + assert 'Guessing Game' in intro_ws and 'two‑thirds of the average' in intro_ws + instr = (PROJECT_DIR / 'policy_guess_anchoring' / 'instructions.html').read_text(encoding='utf-8') + assert 'Rules' in instr and 'two‑thirds of the average' in instr + results = (PROJECT_DIR / 'policy_guess_anchoring' / 'Results.html').read_text(encoding='utf-8') + assert 'Debrief' in results and 'include_sibling' not in results + + +def test_guessing_game_e2e_group_of_6(): + run_otree_test_with_n('guessing_game_demo', 6) diff --git a/tests/test_e2e_bots.py b/tests/test_e2e_bots.py new file mode 100644 index 0000000000000000000000000000000000000000..9b6ee66809c7312bf575344e298570f917280dc5 --- /dev/null +++ b/tests/test_e2e_bots.py @@ -0,0 +1,48 @@ +import os +import subprocess +from pathlib import Path +import time + + +PROJECT_DIR = Path(__file__).resolve().parents[1] +OTREE = PROJECT_DIR.parents[1] / 'venv' / 'bin' / 'otree' + + +def run_otree_test(app: str): + env = os.environ.copy() + # Ensure a fresh SQLite DB per test run to avoid schema/version conflicts + db_name = f"test_db_{app}_{int(time.time()*1000)}.sqlite3" + env['OTREE_DATABASE_URL'] = f"sqlite:///{db_name}" + # Also remove default db.sqlite3 if present + default_db = PROJECT_DIR / 'db.sqlite3' + if default_db.exists(): + default_db.unlink() + proc = subprocess.run( + [str(OTREE), 'test', app], + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + if proc.returncode != 0: + print(proc.stdout) + assert proc.returncode == 0, f"oTree bot tests failed for {app}" + + +def test_bots_policy_nudges_sequence(): + run_otree_test('policy_nudges') + + +def test_bots_guessing_game_demo_sequence(): + run_otree_test('guessing_game_demo') + + +def test_bots_survey_biases_sequence(): + run_otree_test('survey_biases_full') + + +def test_bots_classic_baseline_sequence(): + # Reproduce navigation/rendering issues in the classic baseline sequence + # (e.g., template expressions not supported by oTree's template engine). + run_otree_test('classic_baseline') diff --git a/tests/test_integration_settings.py b/tests/test_integration_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..b14bac5d55b37c46e65128b9f763c4aac61e5ac8 --- /dev/null +++ b/tests/test_integration_settings.py @@ -0,0 +1,26 @@ +from importlib.machinery import SourceFileLoader +from pathlib import Path + + +PROJECT_DIR = Path(__file__).resolve().parents[1] +SETTINGS_PATH = PROJECT_DIR / 'settings.py' + + +def load_settings(): + mod = SourceFileLoader('ibe_pp_settings', str(SETTINGS_PATH)).load_module() + return mod + + +def test_session_configs_present_and_paths_exist(): + settings = load_settings() + cfgs = {c['name']: c for c in settings.SESSION_CONFIGS} + for name in ['classic_baseline', 'policy_nudges', 'guessing_game_demo', 'survey_biases_full']: + assert name in cfgs + # Check app folders exist for policy_nudges sequence + apps = cfgs['policy_nudges']['app_sequence'] + for app in apps: + if app in ('payment_info',): + continue + path = PROJECT_DIR / app + assert path.exists(), f"App folder missing: {path}" + diff --git a/tests/test_policy_content_templates.py b/tests/test_policy_content_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..8a1cff7576bbe16571286732a2a5549fc7000a3e --- /dev/null +++ b/tests/test_policy_content_templates.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +PROJECT_DIR = Path(__file__).resolve().parents[1] + + +def test_policy_dictator_results_no_instructions_include(): + t = (PROJECT_DIR / 'policy_dictator_norms' / 'Results.html').read_text(encoding='utf-8') + assert 'include_sibling' not in t, 'Results should not include instructions in policy_dictator_norms' + + +def test_policy_dictator_offer_has_endowment_callout(): + t = (PROJECT_DIR / 'policy_dictator_norms' / 'Offer.html').read_text(encoding='utf-8') + assert 'Your endowment' in t and '{{ C.ENDOWMENT }}' in t + + +def test_policy_trust_send_has_endowment_callout(): + t = (PROJECT_DIR / 'policy_trust_framed' / 'Send.html').read_text(encoding='utf-8') + assert 'Your investment capital:' in t and '{{ C.ENDOWMENT }}' in t + + +def test_policy_trust_sendback_has_tripled_callout(): + t = (PROJECT_DIR / 'policy_trust_framed' / 'SendBack.html').read_text(encoding='utf-8') + assert 'tripled by the city fund to' in t and '{{ tripled_amount }}' in t + diff --git a/tests/test_randomization_db.py b/tests/test_randomization_db.py new file mode 100644 index 0000000000000000000000000000000000000000..95ecd12380a58fb2fe3210a30b2818f2456ece6e --- /dev/null +++ b/tests/test_randomization_db.py @@ -0,0 +1,75 @@ +import os +import sqlite3 +import subprocess +import time +from pathlib import Path + +import pytest + + +PROJECT_DIR = Path(__file__).resolve().parents[1] +OTREE_BIN = PROJECT_DIR.parents[1] / 'venv' / 'bin' / 'otree' + + +def _run_otree_and_return_db(session: str, n: int) -> Path: + env = os.environ.copy() + db_path = PROJECT_DIR / f"tmp_rand_{session}_{n}_{int(time.time()*1000)}.sqlite3" + env['OTREE_DATABASE_URL'] = f"sqlite:///{db_path}" + # Remove default project DB to avoid accidental reuse + default_db = PROJECT_DIR / 'db.sqlite3' + if default_db.exists(): + default_db.unlink() + proc = subprocess.run( + [str(OTREE_BIN), 'test', session, str(n)], + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + if proc.returncode != 0: + print(proc.stdout) + assert proc.returncode == 0, f"oTree test failed for {session} N={n}" + return db_path + + +def _fetch_orders(db_path: Path): + con = sqlite3.connect(str(db_path)) + try: + cur = con.cursor() + cur.execute( + "SELECT order_a, order_b, order_c FROM policy_survey_biases_player WHERE round_number=1" + ) + rows = cur.fetchall() + a = [r[0] for r in rows] + b = [r[1] for r in rows] + c = [r[2] for r in rows] + return a, b, c + finally: + con.close() + + +@pytest.mark.parametrize('n', [2, 3, 6]) +def test_persisted_orders_balanced_per_session(n): + db = _run_otree_and_return_db('survey_biases_full', n) + a, b, c = _fetch_orders(db) + for arr in (a, b, c): + counts = {v: arr.count(v) for v in set(arr)} + assert len(counts) == 2, f"Expected 2 conditions, got {counts}" + diff = abs(list(counts.values())[0] - list(counts.values())[1]) + assert diff <= 1, (n, arr, counts) + + +def test_persisted_orders_vary_across_runs(): + seen = dict(a=set(), b=set(), c=set()) + n = 6 + for _ in range(3): + db = _run_otree_and_return_db('survey_biases_full', n) + a, b, c = _fetch_orders(db) + seen['a'].update(a) + seen['b'].update(b) + seen['c'].update(c) + assert seen['a'] == {'info_before', 'info_after'} + assert seen['b'] == {'support_first', 'eval_first'} + assert seen['c'] == {'recall_first', 'opinion_first'} + diff --git a/tests/test_randomization_variation.py b/tests/test_randomization_variation.py new file mode 100644 index 0000000000000000000000000000000000000000..475b37d3d1198f7fd1ba30c2d2263ac4128ebcba --- /dev/null +++ b/tests/test_randomization_variation.py @@ -0,0 +1,76 @@ +import random +import types + +import importlib + +# Import app module once to avoid re-declaring ORM tables across tests +APP_MOD = importlib.import_module('policy_survey_biases.__init__') + + +def _make_stub_players(n): + class StubParticipant: + def __init__(self, pid): + self.id_in_session = pid + self.vars = {} + + class StubPlayer: + def __init__(self, pid): + self.participant = StubParticipant(pid) + self.order_a = None + self.order_b = None + self.order_c = None + + def field_maybe_none(self, name): + return getattr(self, name, None) + + return [StubPlayer(i + 1) for i in range(n)] + + +class StubSubsession: + def __init__(self, n): + self._players = _make_stub_players(n) + + def get_players(self): + return self._players + + +def _assign_once(n): + subsession = StubSubsession(n) + # Call the app's method directly with our stub — Python only needs duck-typing + APP_MOD.Subsession.creating_session(subsession) + players = subsession.get_players() + a = [p.order_a for p in players] + b = [p.order_b for p in players] + c = [p.order_c for p in players] + return a, b, c + + +def test_balanced_assignment_single_session(): + for n in (2, 3, 6, 7): + a, b, c = _assign_once(n) + # Each binary assignment should be as even as possible (difference <= 1) + for arr in (a, b, c): + counts = {v: arr.count(v) for v in set(arr)} + if len(counts) == 2: + diff = abs(list(counts.values())[0] - list(counts.values())[1]) + assert diff <= 1, (n, arr, counts) + else: + # Degenerate case n==1 or all same — not expected here + assert n <= 1, (n, arr, counts) + + +def test_variation_across_sessions(): + random.seed() # ensure non-deterministic shuffles across runs + seen_a = set() + seen_b = set() + seen_c = set() + n = 6 + # Run multiple times to detect shuffling variation + for _ in range(10): + a, b, c = _assign_once(n) + seen_a.update(a) + seen_b.update(b) + seen_c.update(c) + assert seen_a == {'info_before', 'info_after'} + assert seen_b == {'support_first', 'eval_first'} + assert seen_c == {'recall_first', 'opinion_first'} diff --git a/tests/test_session_matrix_and_rooms.py b/tests/test_session_matrix_and_rooms.py new file mode 100644 index 0000000000000000000000000000000000000000..80d3050a92cf7cb0e08d3b26349e7b8b6d1c0ef9 --- /dev/null +++ b/tests/test_session_matrix_and_rooms.py @@ -0,0 +1,84 @@ +import os +import subprocess +import time +from pathlib import Path + +import pytest + + +PROJECT_DIR = Path(__file__).resolve().parents[1] +OTREE_BIN = PROJECT_DIR.parents[1] / 'venv' / 'bin' / 'otree' + + +def run_otree_test_with_n(session: str, n: int, expect_ok: bool = True): + env = os.environ.copy() + db_name = f"tmp_{session}_{n}_{int(time.time()*1000)}.sqlite3" + env['OTREE_DATABASE_URL'] = f"sqlite:///{db_name}" + # Remove default project DB to avoid accidental reuse + default_db = PROJECT_DIR / 'db.sqlite3' + if default_db.exists(): + default_db.unlink() + proc = subprocess.run( + [str(OTREE_BIN), 'test', session, str(n)], + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + if expect_ok and proc.returncode != 0: + print(proc.stdout) + if not expect_ok and proc.returncode == 0: + print(proc.stdout) + if expect_ok: + assert proc.returncode == 0, f"Expected success for {session} with N={n}" + else: + assert proc.returncode != 0, f"Expected failure for {session} with N={n}" + return proc + + +@pytest.mark.parametrize( + 'session,n', + [ + ('classic_baseline', 6), + ('classic_baseline', 12), + ('policy_nudges', 6), + ('policy_nudges', 12), + ('guessing_game_demo', 3), + ('guessing_game_demo', 6), + ('guessing_game_demo', 9), + ('survey_biases_full', 1), + ('survey_biases_full', 3), + ], +) +def test_session_matrix_valid_groupings(session, n): + run_otree_test_with_n(session, n, expect_ok=True) + + +@pytest.mark.parametrize( + 'session,n', + [ + # LCM of (2,2,3) = 6, so non-multiples should fail to complete + ('classic_baseline', 5), + ('policy_nudges', 4), + ], +) +def test_session_matrix_invalid_groupings_fail(session, n): + proc = run_otree_test_with_n(session, n, expect_ok=False) + # Heuristic check: output should mention failure/timeout/wait + out = proc.stdout.lower() + assert ('failed' in out) or ('error' in out) or ('timeout' in out) or ('traceback' in out) + + +def test_rooms_and_labels_exist(): + # settings.py declares ROOMS with lab_demo; ensure label file exists and is non-empty + labels = PROJECT_DIR / '_rooms' / 'lab_demo.txt' + assert labels.exists(), 'Expected _rooms/lab_demo.txt to exist' + lines = [ln.strip() for ln in labels.read_text(encoding='utf-8').splitlines() if ln.strip()] + assert len(lines) >= 2, 'Expected at least 2 participant labels in lab_demo.txt' + + +def test_static_assets_present(): + css = PROJECT_DIR / '_static' / 'custom.css' + assert css.exists() and css.stat().st_size > 0, 'custom.css is missing or empty' + diff --git a/tests/test_static_http.py b/tests/test_static_http.py new file mode 100644 index 0000000000000000000000000000000000000000..0182584b3ff0a30ec4e9ec5168ea44e190dd95bb --- /dev/null +++ b/tests/test_static_http.py @@ -0,0 +1,54 @@ +import os +import subprocess +import time + +import pytest + + +@pytest.mark.skipif( + not os.environ.get('RUN_OTREE_HTTP_TESTS'), + reason='Set RUN_OTREE_HTTP_TESTS=1 to enable devserver HTTP checks', +) +def test_custom_css_served_over_http(tmp_path): + import requests + from pathlib import Path + + project = Path(__file__).resolve().parents[1] + otree = project.parents[1] / 'venv' / 'bin' / 'otree' + port = os.environ.get('OTREE_TEST_PORT', '8081') + + env = os.environ.copy() + env['OTREE_DATABASE_URL'] = f"sqlite:///{tmp_path/'http_test.sqlite3'}" + + # Start devserver + proc = subprocess.Popen( + [str(otree), 'devserver', '--port', port], + cwd=str(project), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + # Wait briefly for server to start + deadline = time.time() + 10 + base = f"http://127.0.0.1:{port}" + url = f"{base}/_static/custom.css" + ok = False + while time.time() < deadline: + try: + r = requests.get(url, timeout=0.5) + if r.status_code == 200 and 'body' in r.text: + ok = True + break + except Exception: + pass + time.sleep(0.5) + assert ok, f"Did not get 200 for {url} within timeout" + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + diff --git a/tests/test_survey_content_templates.py b/tests/test_survey_content_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..a4e007e08042cde0e2c42cc73e5945627a556d49 --- /dev/null +++ b/tests/test_survey_content_templates.py @@ -0,0 +1,35 @@ +from pathlib import Path + + +PROJECT_DIR = Path(__file__).resolve().parents[1] + + +def test_overconfidence_units_and_guidance(): + t = (PROJECT_DIR / 'policy_survey_biases' / 'Overconfidence.html').read_text(encoding='utf-8') + assert '90% confidence interval' in t + # Updated units after content change + for phrase in ('millions', 'percent'): + assert phrase in t + + +def test_overplacement_scale_anchors_present(): + t = (PROJECT_DIR / 'policy_survey_biases' / 'Overplacement.html').read_text(encoding='utf-8') + assert 'Scale anchors' in t and '1' in t and '7' in t + + +def test_confirmation_before_after_clarity(): + t = (PROJECT_DIR / 'policy_survey_biases' / 'Confirmation.html').read_text(encoding='utf-8') + assert 'Before' in t or 'before' in t + assert 'After' in t or 'after' in t + + +def test_availability_prompt_and_anchors(): + t = (PROJECT_DIR / 'policy_survey_biases' / 'Availability.html').read_text(encoding='utf-8') + assert 'one concrete news story' in t or 'news story' in t + assert 'Anchors' in t and '1' in t and '7' in t + + +def test_results_summary_mentions_measures(): + t = (PROJECT_DIR / 'policy_survey_biases' / 'Results.html').read_text(encoding='utf-8') + for word in ('confidence', 'calibration', 'self', 'confirmation', 'availability'): + assert word in t diff --git a/tests/test_template_coverage_all_apps.py b/tests/test_template_coverage_all_apps.py new file mode 100644 index 0000000000000000000000000000000000000000..68ea6dc50696f9ac57a7673a8a335b2663655907 --- /dev/null +++ b/tests/test_template_coverage_all_apps.py @@ -0,0 +1,56 @@ +from pathlib import Path +import re + + +PROJECT_DIR = Path(__file__).resolve().parents[1] + + +def iter_app_dirs(): + for p in PROJECT_DIR.iterdir(): + if not p.is_dir(): + continue + if p.name.startswith('_'): + continue + if (p / '__init__.py').exists(): + yield p + + +TEMPLATE_EXPR = re.compile(r"{{\s*([^}]*)\s*}}") + + +def extract_exprs(html_path: Path): + text = html_path.read_text(encoding='utf-8', errors='ignore') + return TEMPLATE_EXPR.findall(text) + + +def test_results_templates_have_next_button(): + missing = [] + for app_dir in iter_app_dirs(): + results = app_dir / 'Results.html' + if results.exists(): + text = results.read_text(encoding='utf-8', errors='ignore') + if '{{ next_button }}' not in text: + missing.append(app_dir.name) + assert not missing, ( + "Missing '{{ next_button }}' in Results.html for apps: " + ", ".join(missing) + ) + + +def test_no_arithmetic_in_any_app_templates(): + offenders = [] + control = { + 'extends', 'block', 'endblock', 'include', 'include_sibling', + 'if', 'elif', 'else', 'endif', 'for', 'endfor', + 'formfields', 'next_button', 'chat', 'load', 'static' + } + for app_dir in iter_app_dirs(): + for html in app_dir.glob('*.html'): + for expr in extract_exprs(html): + head = expr.strip().split()[0] if expr.strip() else '' + if head in control: + continue + if any(op in expr for op in ('-', '*', '/', '+')): + offenders.append(f"{app_dir.name}/{html.name}: {expr.strip()}") + assert not offenders, ( + 'Unsupported arithmetic in template expressions found: ' + '; '.join(offenders) + ) diff --git a/tests/test_template_navigation.py b/tests/test_template_navigation.py new file mode 100644 index 0000000000000000000000000000000000000000..a0e9ddc3485f421bbc409517828f3f1385601a5c --- /dev/null +++ b/tests/test_template_navigation.py @@ -0,0 +1,23 @@ +from pathlib import Path + + +PROJECT_DIR = Path(__file__).resolve().parents[1] + + +def assert_results_has_next(app_dir: Path): + results = app_dir / 'Results.html' + assert results.exists(), f"Missing Results.html in {app_dir.name}" + text = results.read_text(encoding='utf-8') + assert '{{ next_button }}' in text, ( + f"Results.html in {app_dir.name} is missing '{{ next_button }}' — " + "participants cannot proceed to the next app in a sequence" + ) + + +def test_classic_sequence_has_next_buttons(): + # Apps in classic_baseline sequence expected to have a next button on Results + for app in ['prisoner', 'trust_simple', 'public_goods_simple']: + app_dir = PROJECT_DIR / app + assert app_dir.exists(), f"App folder not found: {app}" + assert_results_has_next(app_dir) + diff --git a/tests/test_template_syntax.py b/tests/test_template_syntax.py new file mode 100644 index 0000000000000000000000000000000000000000..d6d6f1068864b7bacc52228c1db0cdc328864882 --- /dev/null +++ b/tests/test_template_syntax.py @@ -0,0 +1,25 @@ +from pathlib import Path +import re + + +PROJECT_DIR = Path(__file__).resolve().parents[1] + +TEMPLATE_EXPR = re.compile(r"{{\s*([^}]*)\s*}}") + + +def _exprs_in(path: Path): + text = path.read_text(encoding='utf-8') + return TEMPLATE_EXPR.findall(text) + + +def test_no_arithmetic_in_templates_public_goods_instructions(): + """oTree's template engine does not support arithmetic in expressions. + This catches cases like `{{ C.PLAYERS_PER_GROUP - 1 }}` or `{{ 60*C.MULTIPLIER }}`. + """ + tpl = PROJECT_DIR / 'public_goods_simple' / 'instructions.html' + assert tpl.exists(), 'Expected public_goods_simple/instructions.html to exist' + for expr in _exprs_in(tpl): + assert not any(op in expr for op in ('-', '*', '/', '+')), ( + f"Unsupported arithmetic found in template expression: {expr}" + ) + diff --git a/traveler_dilemma/Claim.html b/traveler_dilemma/Claim.html new file mode 100644 index 0000000000000000000000000000000000000000..07e022352790046720bdccac4ff8067772938f5d --- /dev/null +++ b/traveler_dilemma/Claim.html @@ -0,0 +1,9 @@ +{{ block title }}Claim{{ endblock }} +{{ block content }} + + {{ formfields }} + {{ next_button }} + + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/traveler_dilemma/Introduction.html b/traveler_dilemma/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..9d7b251fdbb167084c2a10f572ebb528c742672b --- /dev/null +++ b/traveler_dilemma/Introduction.html @@ -0,0 +1,7 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ {{ include_sibling 'instructions.html' }} + {{ next_button }} +{{ endblock }} diff --git a/traveler_dilemma/Results.html b/traveler_dilemma/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..48cfb9ef8056186433c123b11c92301e61f628b8 --- /dev/null +++ b/traveler_dilemma/Results.html @@ -0,0 +1,29 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + + + + + + + + + + + + + + + + + + + + + + +
You claimed{{ player.claim }}
The other traveler claimed{{ other_player_claim }}
Winning claim (i.e. lower claim){{ group.lower_claim }}
Your adjustment{{ player.adjustment }}
Thus you receive{{ player.payoff }}
+ +

{{ next_button }}

+ {{ include_sibling 'instructions.html' }} +{{ endblock }} diff --git a/traveler_dilemma/__init__.py b/traveler_dilemma/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8048a630dc3535824bba54a7bb079c528f192390 --- /dev/null +++ b/traveler_dilemma/__init__.py @@ -0,0 +1,93 @@ +from otree.api import * + + +doc = """ +Kaushik Basu's famous traveler's dilemma ( + + AER 1994 +). +It is a 2-player game. The game is framed as a traveler's dilemma and intended +for classroom/teaching use. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'traveler_dilemma' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + # Player's reward for the lowest claim""" + ADJUSTMENT_ABS = cu(2) + # Player's deduction for the higher claim + # The maximum claim to be requested + MAX_AMOUNT = cu(100) + # The minimum claim to be requested + MIN_AMOUNT = cu(2) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + lower_claim = models.CurrencyField() + + +class Player(BasePlayer): + claim = models.CurrencyField( + min=C.MIN_AMOUNT, + max=C.MAX_AMOUNT, + label='How much will you claim for your antique?', + doc=""" + Each player's claim + """, + ) + adjustment = models.CurrencyField() + + +# FUNCTIONS +def set_payoffs(group: Group): + p1, p2 = group.get_players() + if p1.claim == p2.claim: + group.lower_claim = p1.claim + for p in [p1, p2]: + p.payoff = group.lower_claim + p.adjustment = cu(0) + else: + if p1.claim < p2.claim: + winner = p1 + loser = p2 + else: + winner = p2 + loser = p1 + group.lower_claim = winner.claim + winner.adjustment = C.ADJUSTMENT_ABS + loser.adjustment = -C.ADJUSTMENT_ABS + winner.payoff = group.lower_claim + winner.adjustment + loser.payoff = group.lower_claim + loser.adjustment + + +def other_player(player: Player): + return player.get_others_in_group()[0] + + +# PAGES +class Introduction(Page): + pass + + +class Claim(Page): + form_model = 'player' + form_fields = ['claim'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + @staticmethod + def vars_for_template(player: Player): + return dict(other_player_claim=other_player(player).claim) + + +page_sequence = [Introduction, Claim, ResultsWaitPage, Results] diff --git a/traveler_dilemma/instructions.html b/traveler_dilemma/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..3ac4268f5432d0631fc67ef4f7a3995a658600a3 --- /dev/null +++ b/traveler_dilemma/instructions.html @@ -0,0 +1,36 @@ + +
+
+ +

+ Instructions +

+ +

+ You have been randomly and anonymously paired with another participant. + Now please image the following scenario. +

+

+ You and another traveler (the other participant) just returned from a + remote island where both of you bought the same antiques. + Unfortunately, you discovered that your airline managed to smash the + antiques, as they always do. The airline manager assures you of + adequate compensation. Without knowing the true value of your antiques, + he offers you the following scheme. Both of you simultaneously and + independently make a claim for the value of your own antique (ranging + from {{ C.MIN_AMOUNT }} to {{ C.MAX_AMOUNT }}): +

+
    +
  • + If both claim the same amount, then this amount will be paid to + both. +
  • +
  • + If you claim different amounts, then the lower amount will be paid + to both. Additionally, the one with lower claim will receive a + reward of {{ C.ADJUSTMENT_ABS }}; the one with higher claim will + receive a penalty of {{ C.ADJUSTMENT_ABS }}. +
  • +
+
+
\ No newline at end of file diff --git a/trust/Introduction.html b/trust/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..52eddf88a85f8259821c89299be1ed9f52de05bd --- /dev/null +++ b/trust/Introduction.html @@ -0,0 +1,10 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ + {{ include_sibling 'instructions.html' }} + + {{ next_button }} + +{{ endblock }} diff --git a/trust/Results.html b/trust/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..3324de04577160bff098e372b60399666902a300 --- /dev/null +++ b/trust/Results.html @@ -0,0 +1,35 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + + {{ if player.id_in_group == 1 }} +

+ You chose to send participant B {{ group.sent_amount }}. + Participant B returned {{ group.sent_back_amount }}. +

+

+ You were initially endowed with {{ C.ENDOWMENT }}, + chose to send {{ group.sent_amount }}, + received {{ group.sent_back_amount }} + thus you now have: + {{ C.ENDOWMENT }}-{{ group.sent_amount }}+{{ group.sent_back_amount }}={{ player.payoff }} +

+ {{ else }} +

+ Participant A sent you {{ group.sent_amount }}. + They were tripled so you received {{ tripled_amount }}. + You chose to return {{ group.sent_back_amount }}. +

+

+ You received {{ tripled_amount }}, + chose to return {{ group.sent_back_amount }} + thus you now have: + ({{ tripled_amount }})-({{ group.sent_back_amount }})={{ player.payoff }} +

+ . + {{ endif }} + +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/trust/Send.html b/trust/Send.html new file mode 100644 index 0000000000000000000000000000000000000000..a8ff7d5bceb667d0473813cd1d7d84a96e23db6e --- /dev/null +++ b/trust/Send.html @@ -0,0 +1,15 @@ +{{ block title }}Your Choice{{ endblock }} +{{ block content }} + +

+ You are Participant A. Now you have {{C.ENDOWMENT}}. How much will you send to participant B? +

+ + {{ formfields }} +

+ {{ next_button }} +

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/trust/SendBack.html b/trust/SendBack.html new file mode 100644 index 0000000000000000000000000000000000000000..6e4812f882f92bac4ab274858a6ab67ba8346197 --- /dev/null +++ b/trust/SendBack.html @@ -0,0 +1,14 @@ +{{ block title }}Your Choice{{ endblock }} +{{ block content }} +

+ You are Participant B. + Participant A sent you {{ group.sent_amount }} and you received {{ tripled_amount }}. + Now you have {{ tripled_amount }}. + How much will you send to participant A? +

+ + {{ formfields }} +

{{ next_button }}

+ {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/trust/__init__.py b/trust/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d8c2ebd0c06fe355ea6cffb59a2bb1c2d02c8331 --- /dev/null +++ b/trust/__init__.py @@ -0,0 +1,117 @@ +from otree.api import * + + + + +doc = """ +This is a standard 2-player trust game where the amount sent by player 1 gets +tripled. The trust game was first proposed by + + Berg, Dickhaut, and McCabe (1995) +. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'trust' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + # Initial amount allocated to each player + ENDOWMENT = cu(100) + MULTIPLIER = 3 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + sent_amount = models.CurrencyField( + min=0, + max=C.ENDOWMENT, + doc="""Amount sent by P1""", + label="Please enter an amount from 0 to 100:", + ) + sent_back_amount = models.CurrencyField(doc="""Amount sent back by P2""", min=cu(0)) + + +class Player(BasePlayer): + pass + + +# FUNCTIONS +def sent_back_amount_max(group: Group): + return group.sent_amount * C.MULTIPLIER + + +def set_payoffs(group: Group): + p1 = group.get_player_by_id(1) + p2 = group.get_player_by_id(2) + p1.payoff = C.ENDOWMENT - group.sent_amount + group.sent_back_amount + p2.payoff = group.sent_amount * C.MULTIPLIER - group.sent_back_amount + + +# PAGES +class Introduction(Page): + pass + + +class Send(Page): + """This page is only for P1 + P1 sends amount (all, some, or none) to P2 + This amount is tripled by experimenter, + i.e if sent amount by P1 is 5, amount received by P2 is 15""" + + form_model = 'group' + form_fields = ['sent_amount'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 1 + + +class SendBackWaitPage(WaitPage): + pass + + +class SendBack(Page): + """This page is only for P2 + P2 sends back some amount (of the tripled amount received) to P1""" + + form_model = 'group' + form_fields = ['sent_back_amount'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 2 + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + tripled_amount = group.sent_amount * C.MULTIPLIER + return dict(tripled_amount=tripled_amount) + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + """This page displays the earnings of each player""" + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(tripled_amount=group.sent_amount * C.MULTIPLIER) + + +page_sequence = [ + Introduction, + Send, + SendBackWaitPage, + SendBack, + ResultsWaitPage, + Results, +] diff --git a/trust/instructions.html b/trust/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..c3c8a47fddfd7131a19c54643437fa04729ca5ad --- /dev/null +++ b/trust/instructions.html @@ -0,0 +1,27 @@ + +
+
+ +

+ Instructions +

+

+ You have been randomly and anonymously paired with another participant. + One of you will be selected at random to be participant A; + the other will be participant B. + You will learn whether you are participant A or B prior to making any + decision. +

+

+ To start, participant A receives {{ C.ENDOWMENT }}; + participant B receives + nothing. + Participant A can send some or all of + his {{ C.ENDOWMENT }} to participant B. + Before B receives this amount, it will be multiplied + by {{ C.MULTIPLIER }}. Once B receives + the tripled amount he can decide to send some or all of it to + A. +

+
+
\ No newline at end of file diff --git a/trust_simple/Introduction.html b/trust_simple/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..93e68158b7d30186fda0c8e06b88836e8e9acc66 --- /dev/null +++ b/trust_simple/Introduction.html @@ -0,0 +1,12 @@ +{{ block title }}Trust Game — Introduction (Game 2 of 3){{ endblock }} +{{ block content }} +

Note: Points convert to a bonus at the end of the study.

+

+ You will be randomly paired. Participant A may send some of their tokens to Participant B. + The amount sent is multiplied. Participant B may then send some back. There is no communication. +

+

+ Take a moment to read. When ready, click Next to continue. +

+ {{ next_button }} +{{ endblock }} diff --git a/trust_simple/Results.html b/trust_simple/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..f65424fe0bf1c6e8dc5f1467b0f1235eed32ef2f --- /dev/null +++ b/trust_simple/Results.html @@ -0,0 +1,28 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} + + {{ if player.id_in_group == 1 }} +

+ You sent Participant B {{ group.sent_amount }}. + Participant B returned {{ group.sent_back_amount }}. +

+ {{ else }} +

+ Participant A sent you {{ group.sent_amount }}. + You returned {{ group.sent_back_amount }}. +

+ + {{ endif }} + +

+ Therefore, your total payoff is {{ player.payoff }}. +

+ +
+

Next up: Public Goods Game. Click Next to continue.

+ + {{ next_button }} + + + +{{ endblock }} diff --git a/trust_simple/Send.html b/trust_simple/Send.html new file mode 100644 index 0000000000000000000000000000000000000000..f1aa02e25ce9efa6f7427b3ed3c77708b71b5a7b --- /dev/null +++ b/trust_simple/Send.html @@ -0,0 +1,14 @@ +{{ block title }}Trust Game: Your Choice{{ endblock }} +{{ block content }} + + {{ include_sibling 'instructions.html' }} + +

+ You are Participant A. Now you have {{C.ENDOWMENT}}. +

+ + {{ formfields }} + + {{ next_button }} + +{{ endblock }} diff --git a/trust_simple/SendBack.html b/trust_simple/SendBack.html new file mode 100644 index 0000000000000000000000000000000000000000..8ab2847b19b75a292588d8b01e2aafc888edf85b --- /dev/null +++ b/trust_simple/SendBack.html @@ -0,0 +1,14 @@ +{{ block title }}Trust Game: Your Choice{{ endblock }} +{{ block content }} + + {{ include_sibling 'instructions.html' }} + +

+ You are Participant B. Participant A sent you {{ group.sent_amount }} and you received {{ tripled_amount }}. +

+ + {{ formfields }} + + {{ next_button }} + +{{ endblock }} diff --git a/trust_simple/__init__.py b/trust_simple/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a7eb7d7403d834b80216e258179fbcd64d2fc9f8 --- /dev/null +++ b/trust_simple/__init__.py @@ -0,0 +1,90 @@ +from otree.api import * + + +doc = """ +Simple trust game +""" + + +class C(BaseConstants): + NAME_IN_URL = 'trust_simple' + PLAYERS_PER_GROUP = 2 + NUM_ROUNDS = 1 + ENDOWMENT = cu(10) + MULTIPLIER = 3 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + sent_amount = models.CurrencyField( + min=cu(0), + max=C.ENDOWMENT, + doc="""Amount sent by P1""", + label="How much do you want to send to participant B?", + ) + sent_back_amount = models.CurrencyField( + doc="""Amount sent back by P2""", label="How much do you want to send back?" + ) + + +class Player(BasePlayer): + pass + + +# FUNCTIONS +def sent_back_amount_choices(group: Group): + return currency_range(0, group.sent_amount * C.MULTIPLIER, 1) + + +def set_payoffs(group: Group): + p1 = group.get_player_by_id(1) + p2 = group.get_player_by_id(2) + p1.payoff = C.ENDOWMENT - group.sent_amount + group.sent_back_amount + p2.payoff = group.sent_amount * C.MULTIPLIER - group.sent_back_amount + + +# PAGES +class Introduction(Page): + pass + + +class Send(Page): + form_model = 'group' + form_fields = ['sent_amount'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 1 + + +class WaitForP1(WaitPage): + pass + + +class SendBack(Page): + form_model = 'group' + form_fields = ['sent_back_amount'] + + @staticmethod + def is_displayed(player: Player): + return player.id_in_group == 2 + + @staticmethod + def vars_for_template(player: Player): + group = player.group + + return dict(tripled_amount=group.sent_amount * C.MULTIPLIER) + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + pass + + +page_sequence = [Introduction, Send, WaitForP1, SendBack, ResultsWaitPage, Results] diff --git a/trust_simple/instructions.html b/trust_simple/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..7328e61a4c86bc0b62af60bbd602f7b75c73afa1 --- /dev/null +++ b/trust_simple/instructions.html @@ -0,0 +1,16 @@ + +
+
+ +

Trust Game — Rules

+
    +
  • You are randomly paired as Participant A (sender) or Participant B (receiver).
  • +
  • A starts with {{ C.ENDOWMENT }} and may send any amount (0–{{ C.ENDOWMENT }}). + The amount sent is multiplied by {{ C.MULTIPLIER }} and given to B.
  • +
  • B may send any amount back to A (from 0 up to the multiplied amount).
  • +
  • Payoffs: A keeps ({{ C.ENDOWMENT }} − amount sent) + (amount B returns). B keeps ({{ C.MULTIPLIER }} × amount A sent − amount returned).
  • +
  • This is a one‑shot decision (no repeated rounds in this demo). Decisions are anonymous.
  • +
+

Tip: Decide independently; there are no right or wrong answers.

+
+
diff --git a/trust_simple/tests.py b/trust_simple/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..68dc5638c2b54a1a06ac964e087cfeafd32aa069 --- /dev/null +++ b/trust_simple/tests.py @@ -0,0 +1,13 @@ +from otree.api import Bot, Submission, cu +from . import Introduction, Send, SendBack, Results + + +class PlayerBot(Bot): + def play_round(self): + yield Introduction + if self.player.id_in_group == 1: + yield Submission(Send, dict(sent_amount=cu(3))) + else: + yield Submission(SendBack, dict(sent_back_amount=cu(4))) + yield Results + diff --git a/volunteer_dilemma/Decision.html b/volunteer_dilemma/Decision.html new file mode 100644 index 0000000000000000000000000000000000000000..248caed1dc69e07a401e2b31a8693362c3d54695 --- /dev/null +++ b/volunteer_dilemma/Decision.html @@ -0,0 +1,10 @@ +{{ block title }}Your Choice{{ endblock }} +{{ block content }} + + {{ formfields }} +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} + diff --git a/volunteer_dilemma/Introduction.html b/volunteer_dilemma/Introduction.html new file mode 100644 index 0000000000000000000000000000000000000000..9d7b251fdbb167084c2a10f572ebb528c742672b --- /dev/null +++ b/volunteer_dilemma/Introduction.html @@ -0,0 +1,7 @@ +{{ block title }}Introduction{{ endblock }} +{{ block content }} + +

Note: Points convert to a bonus at the end of the study.

+ {{ include_sibling 'instructions.html' }} + {{ next_button }} +{{ endblock }} diff --git a/volunteer_dilemma/Results.html b/volunteer_dilemma/Results.html new file mode 100644 index 0000000000000000000000000000000000000000..3af5810765bd2cba73977e2a5131c77aac13dec4 --- /dev/null +++ b/volunteer_dilemma/Results.html @@ -0,0 +1,20 @@ +{{ block title }}Results{{ endblock }} +{{ block content }} +

+ {{ if player.volunteer }} + You volunteered. As a result, your payoff is + {{ player.payoff }}. + {{ elif group.num_volunteers > 0 }} + You did not volunteer but some did. As a result, your payoff is + {{ player.payoff }}. + {{ else }} + You did not volunteer and no one did. As a result, your payoff is + {{ player.payoff }}. + {{ endif }} +

+ +

{{ next_button }}

+ + {{ include_sibling 'instructions.html' }} + +{{ endblock }} diff --git a/volunteer_dilemma/__init__.py b/volunteer_dilemma/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d8582d579fb3c55ef98245b6224c4af4946b9406 --- /dev/null +++ b/volunteer_dilemma/__init__.py @@ -0,0 +1,70 @@ +from otree.api import * + + + +doc = """ +Each player decides if to free ride or to volunteer from which all will +benefit. +See: Diekmann, A. (1985). Volunteer's dilemma. Journal of Conflict +Resolution, 605-610. +""" + + +class C(BaseConstants): + NAME_IN_URL = 'volunteer_dilemma' + PLAYERS_PER_GROUP = 3 + NUM_ROUNDS = 1 + NUM_OTHER_PLAYERS = PLAYERS_PER_GROUP - 1 + # """Payoff for each player if at least one volunteers""" + GENERAL_BENEFIT = cu(100) + # """Cost incurred by volunteering player""" + VOLUNTEER_COST = cu(40) + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + num_volunteers = models.IntegerField() + + +class Player(BasePlayer): + volunteer = models.BooleanField( + label='Do you wish to volunteer?', doc="""Whether player volunteers""" + ) + + +# FUNCTIONS +def set_payoffs(group: Group): + players = group.get_players() + group.num_volunteers = sum([p.volunteer for p in players]) + if group.num_volunteers > 0: + baseline_amount = C.GENERAL_BENEFIT + else: + baseline_amount = cu(0) + for p in players: + p.payoff = baseline_amount + if p.volunteer: + p.payoff -= C.VOLUNTEER_COST + + +# PAGES +class Introduction(Page): + pass + + +class Decision(Page): + form_model = 'player' + form_fields = ['volunteer'] + + +class ResultsWaitPage(WaitPage): + after_all_players_arrive = set_payoffs + + +class Results(Page): + pass + + +page_sequence = [Introduction, Decision, ResultsWaitPage, Results] diff --git a/volunteer_dilemma/instructions.html b/volunteer_dilemma/instructions.html new file mode 100644 index 0000000000000000000000000000000000000000..911618f25ab4c3ea5151b88b4cd0238af8244c50 --- /dev/null +++ b/volunteer_dilemma/instructions.html @@ -0,0 +1,22 @@ + +
+
+ +

+ Instructions +

+ +

+ You will be grouped randomly and anonymously with + another {{ C.NUM_OTHER_PLAYERS }} + participants. +

+

+ Each of you decides independently and simultaneously whether you will + volunteer or not. If at least one of you volunteers, everyone will get + {{ C.GENERAL_BENEFIT }}. However, the volunteer(s) will + pay {{ C.VOLUNTEER_COST }}. If no one + volunteers, everyone receives nothing. +

+
+
\ No newline at end of file