oTree¶

目录:¶
安装oTree¶
Important note¶
If you publish research done with oTree, you are required to cite this paper. (Citation: Chen, D.L., Schonger, M., Wickens, C., 2016. oTree - An open-source platform for laboratory, online and field experiments. Journal of Behavioral and Experimental Finance, vol 9: 88-97)
Installation¶
If you will use oTree Studio (easiest option), go to otreehub.com.
Read: why you should use oTree Studio.
If you are an advanced programmer, you can use oTree with a text editor.
Python 教程¶
下面是一篇Python基础教程,包括了你在使用oTree时所需的知识。
运行这个文件的最简单办法是使用IDLE(通常在安装Python时被一同安装)。
网络上有很多优秀的python教程,但是请注意这些教程中的许多部分对于oTree编程不是必须的。
教程文件¶
你可以在 这里
下载教程文件。
# Comments start with a # symbol.
####################################################
## 1. Basics
####################################################
# integer
3
# float (floating-point number)
3.14
# Math is what you would expect
1 + 1 # => 2
8 - 1 # => 7
10 * 2 # => 20
35 / 5 # => 7.0
# Enforce precedence with parentheses
(1 + 3) * 2 # => 8
# Boolean Operators
# Note they are
True and False # => False
False or True # => True
# negate with not
not True # => False
not False # => True
# Equality is ==
1 == 1 # => True
2 == 1 # => False
# Inequality is !=
1 != 1 # => False
2 != 1 # => True
# More comparisons
1 < 10 # => True
1 > 10 # => False
2 <= 2 # => True
2 >= 2 # => True
# A string (text) is created with " or '
"This is a string."
'This is also a string.'
# Strings can be added too!
"Hello " + "world!" # => "Hello world!"
# None means an empty/nonexistent value
None # => None
####################################################
## 2. Variables, lists, and dicts
####################################################
# print() displays the value in your command prompt window
print("I'm Python. Nice to meet you!") # => I'm Python. Nice to meet you!
# Variables
some_var = 5
some_var # => 5
# Lists store sequences
li = []
# Add stuff to the end of a list with append
li.append(1) # li is now [1]
li.append(2) # li is now [1, 2]
li.append(3) # li is now [1, 2, 3]
# Access a list like you would any array
# in Python, the first list index is 0, not 1.
li[0] # => 1
# Assign new values to indexes that have already been initialized with =
li[0] = 42
li # => [42, 2, 3]
# You can add lists
other_li = [4, 5, 6]
li + other_li # => [42, 2, 3, 4, 5, 6]
# Get the length with "len()"
len(li) # => 6
# Here is a prefilled dictionary
filled_dict = dict(name='Lancelot', quest="To find the holy grail", favorite_color="Blue")
# Look up values with []
filled_dict['name'] # => 'Lancelot'
# Check for existence of keys in a dictionary with "in"
'name' in filled_dict # => True
'age' in filled_dict # => False
# set the value of a key with a syntax similar to lists
filled_dict["age"] = 30 # now, filled_dict["age"] => 30
####################################################
## 3. Control Flow
####################################################
# Let's just make a variable
some_var = 5
# Here is an if statement.
# prints "some_var is smaller than 10"
if some_var > 10:
print("some_var is totally bigger than 10.")
elif some_var < 10: # This elif clause is optional.
print("some_var is smaller than 10.")
else: # This is optional too.
print("some_var is indeed 10.")
"""
SPECIAL NOTE ABOUT INDENTING
In Python, you must indent your code correctly, or it will not work.
All lines in a block of code must be aligned along the left edge.
When you're inside a code block (e.g. "if", "for", "def"; see below),
you need to indent by 4 spaces.
Examples of wrong indentation:
if some_var > 10:
print("bigger than 10." # error, this line needs to be indented by 4 spaces
if some_var > 10:
print("bigger than 10.")
else: # error, this line needs to be unindented by 1 space
print("less than 10")
"""
"""
For loops iterate over lists
prints:
1
4
9
"""
for x in [1, 2, 3]:
print(x*x)
"""
"range(number)" returns a list of numbers
from zero to the given number MINUS ONE
the following code prints:
0
1
2
3
"""
for i in range(4):
print(i)
####################################################
## 4. Functions
####################################################
# Use "def" to create new functions
def add(x, y):
print('x is', x)
print('y is', y)
return x + y
# Calling functions with parameters
add(5, 6) # => prints out "x is 5 and y is 6" and returns 11
####################################################
## 5. List comprehensions
####################################################
# We can use list comprehensions to loop or filter
numbers = [3,4,5,6,7]
[x*x for x in numbers] # => [9, 16, 25, 36, 49]
numbers = [3, 4, 5, 6, 7]
[x for x in numbers if x > 5] # => [6, 7]
####################################################
## 6. Modules
####################################################
# You can import modules
import random
print(random.random()) # random real between 0 and 1
出处:本页的教程摘录自 Learn Python in Y Minutes,并使用相同的 许可证 发布。
教程¶
本教程包括几个应用的创建。
首先,你必须熟悉Python;Python 教程 是一个简单的教程。
注解
In addition to this tutorial, you should check out oTree Hub’s featured apps section. Find an app that is similar to what you want to build, and learn by example.
Part 1: Simple survey¶
(一个视频教程在这里 YouTube )
我们来创建一个简单的问卷调查——在第一个页面上,将询问参与人的姓名与年龄,之后在第二个页面上,将此信息展示给参与人。
页面¶
此调查问卷包含2个页面:
- 页面1:玩家输入姓名与年龄
- 页面2:玩家看到他们在之前的页面输入的数据
所以,在你的 page_sequence
中创建2个页面:Survey
与 Results
。
页面1¶
首先我们定义 Survey
页面。这个页面包含一个表单,将 form_model
设为 player
并在 form_fields
中选中 name
与 age
。
然后,将模板的标题设为 Enter your information
,并将内容设置如下:
Please enter the following information.
{{ formfields }}
{{ next_button }}
页面2¶
现在我们来定义 Results
页面。
将模板的标题设为 Results
并将内容设定如下:
<p>Your name is {{ player.name }} and your age is {{ player.age }}.</p>
{{ next_button }}
定义session config¶
在侧边栏中,点击 “Session Configs”,创建一个 session config,并将你的问卷调查应用添加进 app_sequence
。
第二部分:公共品博弈¶
(一个视频教程在这里 YouTube )
我们现在创建一个简单的 公共品博弈。公共品博弈是经济学中的经典博弈。
这是一个三人游戏,每位玩家初始获得100点数。每位玩家独立决定为团队贡献出多少自己的点数。团队总贡献值将被乘以2并以均分的方式重新分配给三位玩家。
本应用的全部代码在 这里。
创建应用¶
正如教程的前一部分,创建另一个应用,起名为 my_public_goods
。
常量¶
设置本应用的常量(查看 常量 可获得更多信息)。
- 设置
PLAYERS_PER_GROUP
为3。oTree会自动将玩家分为若干个3人小组。 - 每位玩家初始有1000点数。故定义
ENDOWMENT
并将其设定为1000货币值。 - 团队贡献值将被乘以2。故定义一个整数常量
MULTIPLIER = 2
:
我们就有了下面的常量:
PLAYERS_PER_GROUP = 3
NUM_ROUNDS = 1
ENDOWMENT = cu(1000)
MULTIPLIER = 2
模型¶
在游戏结束之后,我们需要关于玩家的何种数据?我们需要记录每位玩家的贡献值。故在玩家模型中定义 contribution
字段:
class Player(BasePlayer):
contribution = models.CurrencyField(
min=0,
max=C.ENDOWMENT,
label="How much will you contribute?"
)
我们还需要记录玩家在游戏结束时的收益,不过我们无需显式地定义一个 payoff
字段,因为在oTree中,Player类已经包含了一个 payoff
字段。
我们对每一组的何种数据感兴趣?我们可能会对组内总贡献值感兴趣,以及返还给每位玩家的份额。故我们在Group类中定义下面两个字段:
class Group(BaseGroup):
total_contribution = models.CurrencyField()
individual_share = models.CurrencyField()
页面¶
本游戏包含了3个页面:
- 页面1:玩家决定贡献值
- 页面2:等待页面:玩家等待组内其他玩家做选择
- 页面3:玩家被告知结果
页面1:Contribute¶
首先我们来定义 Contribute
。这一页面包含一个表单,所以我们需要定义 form_model
与 form_fields
。具体来说,这一表单让你能够设定玩家的 contribution
字段。(查看 表单 以获得更多信息。)
class Contribute(Page):
form_model = 'player'
form_fields = ['contribution']
现在我们来创建HTML模板。
设置 title
为 Contribute
, content
如下:
<p>
This is a public goods game with
{{ C.PLAYERS_PER_GROUP }} players per group,
an endowment of {{ C.ENDOWMENT }},
and a multiplier of {{ C.MULTIPLIER }}.
</p>
{{ formfields }}
{{ next_button }}
页面2:ResultsWaitPage¶
当所有玩家都完成了 Contribute
页面,玩家的收益就会被计算。添加一个group函数,命名为 set_payoffs
:
def set_payoffs(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 player in players:
player.payoff = C.ENDOWMENT - player.contribution + group.individual_share
当玩家选择了贡献值后,并不能立刻看到结果页面;他们首先需要等待其他玩家选择贡献值。故你需要添加一个 WaitPage
。不妨将其命名为 ResultsWaitPage
。当一位玩家到达等待页面之后,他必须等待组内其他玩家到达。之后所有人才能继续前往下一页面。(查看 等待页面 以获得更多信息)。
在 ResultsWaitPage
中添加 after_all_players_arrive
字段,将其设置如下以触发函数 set_payoffs
:
after_all_players_arrive = 'set_payoffs'
页面3:Results¶
现在来创建 Results
页面。将模板内容设置如下:
<p>
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 }}.
</p>
{{ next_button }}
定义session config¶
我们在应用序列中添加另一个属于 my_public_goods
的session config。
运行代码¶
载入你的项目并打开浏览器,网址为 http://localhost:8000
。
使用print()解决问题¶
我经常在编程论坛上看到下面这样的帖子,”我的程序不能正常运行。我找不到错误在哪, 尽管我已经花了数小时看我的代码”。
解决方案并不是重读你的代码直到你发现错误为止,而是交互式地 测试 你的程序。
最简单地方法就是利用 print()
语句。如果你没有学会这个技巧,那么你将不能高效地编程。
你只需要像下面这样在你的代码中插入一行:
print('group.total_contribution is', group.total_contribution)
将这一行代码插入到你代码无法正常工作的部分,如上面定义的payoff函数。当你在浏览器中进行游戏时,这行代码就会执行,你的print语句将会显示在命令行窗口中(不是你的浏览器中)
你可以在代码中添加很多print语句
print('in payoff function')
contributions = [p.contribution for p in players]
print('contributions:', contributions)
group.total_contribution = sum(contributions)
group.individual_share = group.total_contribution * C.MULTIPLIER / C.PLAYERS_PER_GROUP
print('individual share', group.individual_share)
if group.individual_share > 100:
print('inside if statement')
for player in players:
player.payoff = C.ENDOWMENT - p.contribution + group.individual_share
print('payoff after', p.payoff)
print语句没有显示在控制台窗口/日志中¶
如果你在控制台窗口中没有看到print语句的输出,这意味着这行代码没有被执行!(也就是程序没有正常工作的原因)
可能这是因为你的代码处于不可达的位置,如 return
语句的后面,或在一个总是返回 False
的”if“语句中。尝试将一系列print语句从函数的开头开始向下放置,然后查看它们在何处停止了显示。
或者也可能你的代码在一个从未被调用(执行)的函数中。oTree的内置函数如 creating_session
与 before_next_page
会被自动执行,但是如果你定义了一个自定义方法如 set_payoffs
,你必须记得在某个内置方法中调用它。
第三部分:信任博弈¶
现在我们来创建一个2人 信任博弈,并学习oTree的一些特性。
开始时,玩家1获得10点数;玩家2无点数。玩家1可以将他点数的一部分或者全部给与玩家2。之后玩家2收到的点数会变为原来的3倍。当玩家2收到3倍的点数之后,他可以决定将部分或者全部的点数给予玩家1。
The completed app is here.
创建应用¶
正如教程的前一部分,创建另一个应用,命名为 my_trust
。
常量¶
转到本应用的常量。
首先我们定义应用的常量。初始点数为10,并且捐赠会变为3倍。
class C(BaseConstants):
NAME_IN_URL = 'my_trust'
PLAYERS_PER_GROUP = 2
NUM_ROUNDS = 1
ENDOWMENT = cu(10)
MULTIPLICATION_FACTOR = 3
模型¶
现在我们添加player与group的字段。这里有两种关键的数据需要记录:玩家1的”捐赠“值与玩家2的“返还”值。
直觉上,你可能如下定义Player类中的字段:
# Don't copy paste this
class Player(BasePlayer):
sent_amount = models.CurrencyField()
sent_back_amount = models.CurrencyField()
此模型的问题在于, sent_amount
只对玩家1有意义, sent_back_amount
只对玩家2有意义。所以 sent_back_amount
字段对于玩家1无意义。我们如何才能使得模型更加精确呢?
我们可以将这些字段定义到 Group
层级。这非常合理,因为每一小组恰好有一个 sent_amount
与一个 sent_back_amount
:
class Group(BaseGroup):
sent_amount = models.CurrencyField(
label="How much do you want to send to participant B?"
)
sent_back_amount = models.CurrencyField(
label="How much do you want to send back?"
)
我们定义一个名为 sent_back_amount_choices
的函数为下拉菜单动态地添加数据。这一特性称为 {field_name}_choices
,在这里: 动态表单验证 被详细解释。
def sent_back_amount_choices(group):
return currency_range(
0,
group.sent_amount * C.MULTIPLICATION_FACTOR,
1
)
定义模板与页面¶
我们需要3个页面:
- 玩家1的“Send”页面
- 玩家2的“Send back”页面
- 两位玩家都能看到的“Results”页面。
Send页面¶
class Send(Page):
form_model = 'group'
form_fields = ['sent_amount']
@staticmethod
def is_displayed(player):
return player.id_in_group == 1
我们使用 is_displayed() 让此页面仅显示给玩家1;玩家2跳过此页面。获取更多 id_in_group
的信息,可查看 小组。
将模板的 title
设为 Trust Game: Your Choice
,并且将 content
设置如下:
<p>
You are Participant A. Now you have {{C.ENDOWMENT}}.
</p>
{{ formfields }}
{{ next_button }}
SendBack.html¶
这是玩家2需要查看的将钱返还的页面。将 title
设为 Trust Game: Your Choice
,并将 content
设置如下:
<p>
You are Participant B. Participant A sent you {{group.sent_amount}}
and you received {{tripled_amount}}.
</p>
{{ formfields }}
{{ next_button }}
下面是页面的代码。请注意:
- 我们使用 vars_for_template() 将变量
tripled_amount
传递给模板。你不能直接在HTML代码中做计算,所以这一数字需要使用Python代码计算并传给模板。
class SendBack(Page):
form_model = 'group'
form_fields = ['sent_back_amount']
@staticmethod
def is_displayed(player):
return player.id_in_group == 2
@staticmethod
def vars_for_template(player):
group = player.group
return dict(
tripled_amount=group.sent_amount * C.MULTIPLICATION_FACTOR
)
Results¶
玩家1与玩家2所看到的结果页面有细微的不同。所以我们使用 {{ if }}
语句来判断当前玩家的 id_in_group
。将 title
设置为 Results
,并将内容部分设置如下:
{{ if player.id_in_group == 1 }}
<p>
You sent Participant B {{ group.sent_amount }}.
Participant B returned {{ group.sent_back_amount }}.
</p>
{{ else }}
<p>
Participant A sent you {{ group.sent_amount }}.
You returned {{ group.sent_back_amount }}.
</p>
{{ endif }}
<p>
Therefore, your total payoff is {{ player.payoff }}.
</p>
class Results(Page):
pass
等待页面与页面序列¶
添加2个等待页面:
WaitForP1
(玩家2需要等待玩家1决定捐赠值)ResultsWaitPage
(玩家1需要等待玩家2决定返还值)
在第二个等待页面之后,我们应当计算收益。所以我们定义一个函数,命名为 set_payoffs
:
def set_payoffs(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.MULTIPLICATION_FACTOR - group.sent_back_amount
在 ResultsWaitPage
中,设置 after_all_players_arrive
:
after_all_players_arrive = set_payoffs
确保在page_sequence中页面顺序是正确的:
page_sequence = [
Send,
WaitForP1,
SendBack,
ResultsWaitPage,
Results,
]
在 SESSION_CONFIGS
中增加一个条目¶
在应用序列中创建一个属于 my_trust
的session config。
运行服务器¶
载入你的项目并打开浏览器,网址为 http://localhost:8000
。
概念总览¶
会话¶
在oTree中,一个会话是指参与人参加一系列活动或者游戏的全过程。一个会话的例子如下:
“一些参与人来到实验室并参加一个公共品游戏,以及之后的问卷调查。参与人会得到10欧元作为出场费,并额外获得他们在游戏中获得的收益。”
子会话¶
一个会话就是一系列子会话;子会话是构成会话的“部分”或“模块”。举例来说,如果一个会话由一个公共品游戏及一个后续的问卷调查组成,那么公共品游戏就是子会话1,问卷调查就是子会话2。相应的,每个子会话是一系列页面。举例来说,假设你有一个4页面的公共品游戏与一个2页面的问卷调查:

如果一个游戏重复多轮,那么每一轮都是一个子会话。
小组¶
每个子会话可以被进一步划分为由玩家组成的小组;举例来说,假设你有一个30位玩家的子会话,可划分为每组由2名玩家组成的15个小组。(注意:小组可以在不同子会话中被重新排列。)
对象层级¶
oTree的实体可以组织成下面的层级:
Session
Subsession
Group
Player
- 一个会话是一系列子会话
- 一个子会话包含若干小组
- 一个小组包含若干玩家
- 每位玩家通过一系列页面进行游戏
你可以从低层级的对象来访问任意高层级的对象:
player.participant
player.group
player.subsession
player.session
group.subsession
group.session
subsession.session
参与人¶
在oTree中,术语”玩家”与“参与人”有着不同的含义。参与人与玩家之间的关系与会话与子会话之间的关系一样:

在某个特定的子会话中玩家是参与人的实例。玩家就像是一个由参与人扮演的临时的“角色”。一个参与人在第一个子会话中可以扮演玩家2,而在下一个子会话中可以扮演玩家1,诸如此类。
模型¶
一个oTree应用有3个数据模型:子会话,小组与玩家。
玩家是小组的一部分,小组是子会话的一部分。参考 概念总览。
假设你想要你的实验生成下面这样的数据:
name age is_student
John 30 False
Alice 22 True
Bob 35 False
...
下面的代码展示了如何定义上面的表结构:
class Player(BasePlayer):
name = models.StringField()
age = models.IntegerField()
is_student = models.BooleanField()
所以,一个 模型 实际上就是一张数据库表。一个 字段 就是表中的一列。
字段¶
字段类型¶
BooleanField
(表示 true/false 或者 yes/no 这样的值)CurrencyField
表示货币数量; 参见 货币。IntegerField
FloatField
(表示实数)StringField
(表示字符串)LongStringField
(表示长字符串; 它的表单控件是一个多行文本框)
初始/默认值¶
字段的初始值为 None
,除非你设置了 initial=
:
class Player(BasePlayer):
some_number = models.IntegerField(initial=0)
内置字段与方法¶
玩家,小组与子会话有一些预定义的字段。举例来说, Player
有 payoff
与 id_in_group
字段, in_all_rounds()
与 get_others_in_group()
方法。
这些内置的字段与方法列出如下。
常量¶
C
是推荐存放应用的参数或是玩家之间没有区别的常量的地方。
下面是内置的常量:
如果你不想让自己应用的真实名字被显示在URL中,那么可以定义一个字符串常量 NAME_IN_URL
并赋予你想显示的名字。
常量可以是数字,字符串,布尔值,列表等等类型。但对于更复杂的数据类型如字典,字典的列表等等,你应当在子会话方法中定义,而不是在常量中。举例来说,不要在常量中定义 my_dict
,而应当使用如下代码:
def my_dict(subsession):
return dict(a=[1,2], b=[3,4])
页面¶
参与人看到的每一个页面均由 Page
定义。
page_sequence
变量定义了页面的顺序。
如果游戏有多轮,那么这一页面序列会重复多次(参考 轮次)。
一个 Page
可以有下面这些方法与属性。
is_displayed()¶
如果此页面应当被显示,你可以定义此方法返回 True
;如果此页面应该被跳过,那么可以定义其返回False。如不定义,默认情况下页面将被显示。
举例来说,如果想仅让每组中的玩家2看到此页面:
@staticmethod
def is_displayed(player):
return player.id_in_group == 2
如果仅在第一轮显示此页面:
@staticmethod
def is_displayed(player):
return player.round_number == 1
如果你需要在一些页面之后重复同样的规则,参考 app_after_this_page.
vars_for_template()¶
使用这个方法向模板传递变量。例如:
@staticmethod
def vars_for_template(player):
a = player.num_apples * 10
return dict(
a=a,
b=1 + 1,
)
在模板中你就可以像下面这样访问 a
与 b
:
Variables {{ a }} and {{ b }} ...
oTree automatically passes the following objects to the template:
player
, group
, subsession
, participant
, session
, and C
.
You can access them in the template like this: {{ C.BLAH }}
or {{ player.blah }}
.
如果用户刷新了页面, vars_for_template
会被重新执行。
before_next_page()¶
在这里你可以定义需要在表单验证之后,玩家继续下一个页面之前执行的任意代码。
如果页面通过 is_displayed
被跳过,那么 before_next_page
也会被跳过。
例如:
@staticmethod
def before_next_page(player, timeout_happened):
player.tripled_apples = player.num_apples * 3
app_after_this_page¶
为了跳过整个应用,你可以定义 app_after_this_page
。例如跳过下一个应用,代码如下:
@staticmethod
def app_after_this_page(player, upcoming_apps):
if player.whatever:
return upcoming_apps[0]
upcoming_apps
是 app_sequence
(一个字符串列表)的剩余部分。因此,为了跳过最后一个应用,返回 upcoming_apps[-1]
即可。或者直接返回硬编码字符串(只要字符串在 upcoming_apps
中):
@staticmethod
def app_after_this_page(player, upcoming_apps):
print('upcoming_apps is', upcoming_apps)
if player.whatever:
return "public_goods"
如果此方法没有返回任何值,玩家将正常进行下一个页面。
模板¶
模板语法¶
变量¶
你可以像下面这样显示一个变量:
Your payoff is {{ player.payoff }}.
下面的变量在模板中均可用:
player
: 当前正在浏览页面的玩家group
: 当前玩家所属的小组subsession
: 当前玩家所属的子会话participant
: 当前玩家所属的参与人session
: 当前会话C
- 任何你使用 vars_for_template() 传递的变量。
条件(“if”)¶
注解
oTree 3.x 在模板中使用两种不同的标签:{{ }}
与 {% %}
。但从oTree 5开始,你可以忘了 {% %}
并在任何你想使用的地方只用 {{ }}
。旧格式仍然可用。
使用‘else’:
Complex example:
循环(“for”)¶
{{ for item in some_list }}
{{ item }}
{{ endfor }}
获取字典中的元素¶
在Python代码中使用 my_dict['foo']
的地方,在模板中应当使用 {{ my_dict.foo }}
。
注释¶
{# this is a comment #}
{#
this is a
multiline comment
#}
不正确的使用¶
模板语言仅仅是为了显示值。你不能在其中做数学运算 (+
, *
, /
, -
) 或者修改数字,列表,字符串等等。想要做这些事情,应当使用 vars_for_template()。
模板是如何工作的:一个例子¶
oTree模板是两种语言的混合:
- HTML (使用尖角括号如
<this>
与</this>
。) - 模板标签 (使用大括号如
{{ this }}
与{{ this }}
)
在这个例子中,假定你的模板如下:
<p>Your payoff this round was {{ player.payoff }}.</p>
{{ if subsession.round_number > 1 }}
<p>
Your payoff in the previous round was {{ last_round_payoff }}.
</p>
{{ endif }}
{{ next_button }}
步骤1:oTree扫描模板标签,生成HTML(亦称“服务器端”)¶
oTree使用变量当前的值将模板标签转换为纯HTML,像下面这样:
<p>Your payoff this round was $10.</p>
<p>
Your payoff in the previous round was $5.
</p>
<button class="otree-btn-next btn btn-primary">Next</button>
步骤2:浏览器扫描HTML标签,生成web页面(亦称“浏览器端”)¶
oTree服务器将HTML发送给用户的电脑,用户的web浏览器可以解析这些代码并将其显示为格式化的web页面:

注意浏览器永远不会收到模板标签。
要点¶
如果页面没有如期望正常工作,你可以分开来看上面两步中哪一步出错了。在浏览器中,右键单击并“查看源代码”。(注意:“查看源代码”在分离屏幕模式下可能无法正常工作。)
你可以看到生成的纯HTML(与JavaScript和CSS)。
- 如果HTML代码看起来不是所期望的,那么可能服务器端出现了某种问题。可在
vars_for_template
或者模板标签中寻找错误。 - 如果生成的HTML代码没有任何错误,那么可能你使用了错误的HTML(或者JavaScript)语法。试着将HTML有问题的部分粘贴回模板中,去掉模板标签,编辑它直到输出正常。再尝试将模板标签加回去,使得页面重新成为动态的。
图片(静态文件)¶
在项目中包含图片,视频,第三方JS/CSS库或者其他静态文件的最简单的方法是将它们在线存放,例如使用Dropbox,Imgur,YouTube等等。
然后,将URL包含在 <img> 或 <video> 标签内放在模板中,例如:
<img src="https://i.imgur.com/gM5yeyS.jpg" width="500px" />
你也可以直接将图片存放在你的项目中。(但注意文件较大会影响性能)。oTree Studio有一个图片上传工具。(如果你使用文本编辑器,参考 这里。)将图片存储在项目中后,你可以像下面这样显示它:
<img src="{{ static 'folder_name/puppy.jpg' }}"/>
动态图片¶
如果你需要根据上下文显示不同的图片(比如每一轮显示不同的图片),那么你可以在 vars_for_template
中构建并将其传递给模板,例如:
@staticmethod
def vars_for_template(player):
return dict(
image_path='my_app/{}.png'.format(player.round_number)
)
然后在模板中使用:
<img src="{{ static image_path }}"/>
可包含的模板¶
如果你将相同的内容在多个模板之间复制粘贴,更好的方案是创建一个可包含的模板并使用 {{ include_sibling }}
复用模板。
举例来说,如果在你的游戏中游戏说明需要在每一个页面上重复出现,那么就可以创建一个模板,叫做 instructions.html
,并将说明放入其中,例如:
<div class="card bg-light">
<div class="card-body">
<h3>
Instructions
</h3>
<p>
These are the instructions for the game....
</p>
</div>
</div>
Then use {{ include_sibling 'instructions.html' }}
to insert it anywhere you want.
注解
{{ include_sibling }}
is a new alternative to {{ include }}
.
The advantage is that you can omit the name of the app: {{ include_sibling 'xyz.html' }}
instead of {{ include 'my_app/xyz.html' }}
.
However, if the includable template is in a different folder, you must use {{ include }}
.
JavaScript 与 CSS¶
将JavaScript/CSS代码放在何处¶
你可以使用常见的 <script></script>
或 <style></style>
标签将JavaScript与CSS放在你模板的任意位置。
如果你有很多脚本/样式,你可以将它们放在 content
之外独立的部分: scripts
and styles
中。这并非强制的要求,但这样做会使你的代码更有条理并确保页面按照顺序加载(CSS,然后是页面内容,然后是JavaScript)。
定制主题¶
如果你想要定制oTree元素的外观,下表列出了CSS选择器:
元素 | CSS/jQuery 选择器 |
---|---|
页面主体 | .otree-body |
页面标题 | .otree-title |
等待页面(整个对话框) | .otree-wait-page |
等待页面对话框标题 | .otree-wait-page__title (注意: __ , 而非 _ ) |
等待页面对话框主体 | .otree-wait-page__body |
计时器 | .otree-timer |
下一步按钮 | .otree-btn-next |
表单错误警告 | .otree-form-errors |
举例来说,想要改变页面宽度,将CSS在基模板中放置如下:
<style>
.otree-body {
max-width:800px
}
</style>
获取更多信息,可在浏览器中右键单击你想要修改的元素并选择“审查元素”。然后你可以导航到不同元素并试着改变它们的样式:

尽可能使用上面给出的官方选择器。不要使用任何以 _otree
开头或者基于Bootstrap类的选择器,如 btn-primary
或 card
,因为它们是不稳定的。
从Python向JavaScript传递数据(js_vars)¶
为了在模板中向JavaScript代码传递数据,在你的页面中定义一个 js_vars
方法,例如:
@staticmethod
def js_vars(player):
return dict(
payoff=player.payoff,
)
在模板中,你就可以像下面这样引用这些变量:
<script>
let x = js_vars.payoff;
// etc...
</script>
Bootstrap¶
oTree使用了 Bootstrap,一个流行的用于定制网站用户界面的库。
如果你想要 定制样式,或者 特定组件 如表格,警告,进度条,标签,等等,你可以使用此库。你甚至可以让你的页面变得动态,通过使用组件如 popovers, modals, 和 collapsible text。
使用Bootstrap,通常需要在HTML元素中添加一个 class=
属性。
举例来说,下面得HTML会创建一个“Success” 警告:
<div class="alert alert-success">Great job!</div>
移动设备¶
Bootstrap会在手机或平板上试着显示一个“移动端友好”的版本。
Best way to test on mobile is to use Heroku.otree zipserver
doesn’t accept a ‘port’ argument. Also, devserver/zipserver seem to have issues with shutdown/reloading and freeing up the port.
图表¶
你可以使用任何HTML/JavaScript库来为你的应用添加图表。HighCharts 是一个不错的选择,它可以用来画饼状图,折线图,直方图,时间序列等等。
首先,引入HighCharts JavaScript:
<script src="https://code.highcharts.com/highcharts.js"></script>
在HighCharts demo site 寻找你想要的图表类型。然后点击”edit in JSFiddle”将其编辑成你想要的样子,使用硬编码数据。
然后,复制粘贴JS与HTML到你的模板中,并加载页面。如果你没有看到你的图表,可能是因为JS代码试图插入图表的那一部分HTML缺少了 <div>
标签。
一旦你的图表加载正常,你就可以将硬编码的数据替换为 series
和 categories
这样动态生成的变量。
例如,将下面的代码:
series: [{
name: 'Tokyo',
data: [7.0, 6.9, 9.5, 14.5, 18.2, 21.5, 25.2, 26.5, 23.3, 18.3, 13.9, 9.6]
}, {
name: 'New York',
data: [-0.2, 0.8, 5.7, 11.3, 17.0, 22.0, 24.8, 24.1, 20.1, 14.1, 8.6, 2.5]
}]
改为:
series: js_vars.highcharts_series
…此处 highcharts_series
是你在 js_vars 中定义的变量。
如果你的图表没有加载,在浏览器中单击“查看源码”检查是否是动态生成的数据出现了问题。
杂项¶
你可以使用 to2
,to1
, 或 to0
过滤器来对数字取整。例如: {{ 0.1234|to2}}
的输出是 0.12。
表单¶
oTree中的每一个页面均可包含表单,玩家填完表单之后点击 “Next” 按钮即提交了表单。为了创建一个表单,首先你需要在模型中添加字段,举例如下:
class Player(BasePlayer):
name = models.StringField(label="Your name:")
age = models.IntegerField(label="Your age:")
然后在你的Page类中,设置 form_model
与 form_fields
:
class Page1(Page):
form_model = 'player'
form_fields = ['name', 'age'] # this means player.name, player.age
当用户提交表单时,所提交的数据自动保存到模型对应的字段中。
简单的表单验证¶
最小值与最大值¶
验证一个整数在12到24之间:
offer = models.IntegerField(min=12, max=24)
如果最大值/最小值不是固定的,应当使用 {field_name}_max()
选项¶
如果你想让字段表现为一个有一系列选项的下拉菜单,设置 choices=
:
level = models.IntegerField(
choices=[1, 2, 3],
)
使用单选按钮而不是下拉菜单,应当设置 widget
为 RadioSelect
或 RadioSelectHorizontal
:
level = models.IntegerField(
choices=[1, 2, 3],
widget=widgets.RadioSelect
)
如果选项的列表是被动态决定的,使用 {field_name}_choices()
你也可以通过一个[value, display] 对的列表来显示每一个选项的名字:
level = models.IntegerField(
choices=[
[1, 'Low'],
[2, 'Medium'],
[3, 'High'],
]
)
如果这样设置了,用户就会看见一个有”Low”, “Medium”, “High”的菜单,但是得到的值仍会被记录为1,2,或 3。
你可以这样设置 BooleanField
,StringField
,等等:
cooperated = models.BooleanField(
choices=[
[False, 'Defect'],
[True, 'Cooperate'],
]
)
你可以通过类似下面的方法获得一个与用户的选择对应的人类可读的标签:
player.cooperated # returns e.g. False
player.field_display('cooperated') # returns e.g. 'Defect'
注解
field_display
在oTree 5.4(2021年8月)中新引入。
动态表单验证¶
上述的 min
,max
,与 choices
仅为固定值(常量)。
如果你想让它们动态地被决定(例如在不同玩家间有区别),那么你可以定义下面这些函数。
{field_name}_choices()¶
与设置了 choices=
相同,这一方法会设置表单字段的选项(例如下拉菜单或者单选按钮)。
例如:
class Player(BasePlayer):
fruit = models.StringField()
def fruit_choices(player):
import random
choices = ['apple', 'kiwi', 'mango']
random.shuffle(choices)
return choices
{field_name}_max()¶
此函数为在模型字段中动态地设置 max=
的方法。例如:
class Player(BasePlayer):
offer = models.CurrencyField()
budget = models.CurrencyField()
def offer_max(player):
return player.budget
{field_name}_min()¶
此函数为在模型字段中动态地设置 min=
的方法。
{field_name}_error_message()¶
这是验证一个字段的最灵活的方法。
class Player(BasePlayer):
offer = models.CurrencyField()
budget = models.CurrencyField()
def offer_error_message(player, value):
print('value is', value)
if value > player.budget:
return 'Cannot offer more than your remaining budget'
同时验证多个字段¶
假设你的表单中有3个数字字段,它们的和必须为100。你可以通过 error_message
函数确保这一点,在页面中:
class MyPage(Page):
form_model = 'player'
form_fields = ['int1', 'int2', 'int3']
@staticmethod
def error_message(player, values):
print('values is', values)
if values['int1'] + values['int2'] + values['int3'] != 100:
return 'The numbers must add up to 100'
注意:
- 如果一个字段未被填写(并且你设置了
blank=True
),它的值就会为None
。 - 此函数仅会在表单中没有任何其他错误的情况下会被执行。
- 你也可以返回一个记录着字段名及其对应的错误信息的字典。在这种方法中,你不需要写很多重复的FIELD_error_message方法(参考 这里)。
动态决定表单字段¶
如果你需要表单字段是动态的,你可以定义 get_form_fields
函数取代 form_fields
:
@staticmethod
def get_form_fields(player):
if player.num_bids == 3:
return ['bid_1', 'bid_2', 'bid_3']
else:
return ['bid_1', 'bid_2']
控件¶
你可以设定一个模型字段的 widget
为 RadioSelect
或 RadioSelectHorizontal
如果你想让选项表现为单选按钮而不是下拉菜单的话。
{{ formfield }}¶
如果你想分别设定字段的位置,可以使用 {{ formfield }}
替代 {{ formfields }}
:
{{ formfield 'bid' }}
你也可以将 label
直接放在模板中:
{{ formfield 'bid' label="How much do you want to contribute?" }}
之前的语法 {% formfield player.bid %}
仍然可用。
定制字段的外观¶
{{ formfields }}
与 {{ formfield }}
很容易使用,因为它们自动输出了一个表单字段所必需的所有部分(输入框,标签,错误信息),并使用Bootstrap的样式。
然而,如果你想要自己更多地控制外观与布局,你可以使用手动字段渲染。使用 {{ form.my_field }}
,而不是 {{ formfield 'my_field' }}
来获取输入元素。只需记得还要包含 {{ formfield_errors 'my_field' }}
。
Example: Radio buttons arranged like a slider¶
pizza = models.IntegerField(
widget=widgets.RadioSelect,
choices=[-3, -2, -1, 0, 1, 2, 3]
)
<p>Choose the point on the scale that represents how much you like pizza:</p>
<p>
Least
{{ for choice in form.pizza }}
{{ choice }}
{{ endfor }}
Most
</p>
例子:表格中的单选按钮与其他定制布局¶
假定你在模型中有很多 IntegerField
:
class Player(BasePlayer):
offer_1 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_2 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_3 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_4 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_5 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
并且你想要它们表现得像李克特量表,每一个选项在独立的一列中。
(首先,试着消除你的模型中的代码冗余,根据 如何创建多个字段 中的提示。)
由于选项必须在独立的表格单元中,原生的 RadioSelectHorizontal
控件不会正常工作。
作为替代方案,你应当简单地通过循环重复字段的选项,如下所示:
<tr>
<td>{{ form.offer_1.label }}</td>
{{ for choice in form.offer_1 }}
<td>{{ choice }}</td>
{{ endfor }}
</tr>
如果数个字段均有相同数量的选项,你可以将其组织成表格:
<table class="table">
{{ for field in form }}
<tr>
<th>{{ field.label }}</th>
{{ for choice in field }}
<td>{{ choice }}</td>
{{ endfor }}
</tr>
{{ endfor }}
</table>
原始HTML控件¶
如果 {{ formfields }}
与 手动字段渲染 不能实现你想要的外观,你可以使用原始HTML写你自己的控件。然而,这样做会失去由oTree自动处理的一些便捷的特性。举例来说,如果表单有一个错误并且页面重载了,所有用户已经输入的条目可能被清除。
首先,添加一个 <input>
元素。例如,如果你的 form_fields
包含 my_field
,你可以这样写: <input name="my_field" type="checkbox" />
(其他一些常见的type为 radio
,text
,number
,与 range
)。
其次,你通常应当包含 {{ formfield_errors 'xyz' }}
,这样如果参与人提交了一个不正确的值或者缺少了某些值时,他们就可以看见报错信息。
原始HTML的例子:使用JavaScript定制用户界面¶
假设你不想让用户填写表单,而是与某种可视化的应用交互,比如在图表上点击或者玩一个图形游戏等等。或者,你想要记录一些额外数据,比如用户在页面的某个部分花了多少时间,用户点击了多少次,诸如此类。
首先,使用HTML与JavaScript构建你的界面。然后使用JavaScript将结果写入一个隐藏的表单字段中。例如:
# Player class
contribution = models.IntegerField()
# page
form_fields = ['contribution']
# HTML
<input type="hidden" name="contribution" id="contribution" />
# JavaScript
document.getElementById('contribution').value = 42;
当页面被提交的时候,隐藏输入中的值会被记录到oTree中,如其他表单字段一样。
如果它没有正常工作,打开你浏览器的JavaScript控制台,查看是否有错误,并使用 console.log()
(JavaScript中的 print()
)来逐行地追踪你代码的执行。
按钮¶
提交表单的按钮¶
如果你的页面只包含一个问题,你可以忽略 {{ next_button }}
,并让用户在一系列按钮中点击一个,并前往下一个页面。
举例来说,假定你的玩家模型有 offer_accepted = models.BooleanField()
, 并且你希望像下面这样展示按钮,而不是一个单选按钮:

首先,将 offer_accepted
放入你的页面的 form_fields
。然后将这样的代码放入模板中:
<p>Do you wish to accept the offer?</p>
<button name="offer_accepted" value="True">Yes</button>
<button name="offer_accepted" value="False">No</button>
你可以在任何类型的字段上使用这个技巧,不只是 BooleanField
。
非提交表单的按钮¶
如果按钮有别的用途,而不是用来提交表单,添加 type="button"
:
<button>
Clicking this will submit the form
</button>
<button type="button">
Clicking this will not submit the form
</button>
杂项与进阶¶
具有动态标签的表单字段¶
如果标签包含变量,你可以在页面中构建字符串:
class Contribute(Page):
form_model = 'player'
form_fields = ['contribution']
@staticmethod
def vars_for_template(player):
return dict(
contribution_label='How much of your {} do you want to contribute?'.format(player.endowment)
)
然后在模板中:
{{ formfield 'contribution' label=contribution_label }}
如果你想使用这个技巧,你可能也想使用 动态表单验证。
JavaScript access to form inputs¶
注解
New beta feature as of oTree 5.9 (July 2022)
In your JavaScript code you can use forminputs.xyz
to access the <input>
element of form field xyz
. For example, you can do:
// get the value of an input
forminputs.xyz.value; // returns '42' or '' etc.
// set the value of a field.
forminputs.xyz.value = '42';
// dynamically set a field's properties -- readonly, size, step, pattern, etc.
forminputs.xyz.minlength = '10';
// do live calculations on inputs
function myFunction() {
let sum = parseInt(forminputs.aaa.value) + parseInt(forminputs.bbb.value);
alert(`Your total is ${sum}`);
}
// set an event handler (for oninput/onchange/etc)
forminputs.aaa.oninput = myFunction;
// call methods on an input
forminputs.xyz.focus();
forminputs.xyz.reportValidity();
Radio widgets work a bit differently:
my_radio = models.IntegerField(
widget=widgets.RadioSelect,
choices=[1, 2, 3]
)
// forminputs.my_radio is a RadioNodeList, not a single <input>
// so you need to loop over all 3 options:
for (let radio of forminputs.my_radio) {
radio.required = false;
}
for (let radio of forminputs.my_radio) {
radio.onclick = function() { alert("radio button changed"); };
}
// but the 'value' attribute works the same way as non-radio widgets
forminputs.my_radio.value = 2;
多人游戏¶
小组¶
你可以在多人游戏中将玩家分为多个小组。(如果你只是需要实验意义上的分组,组内玩家并不真的产生交互,那么参考 实验组。)
要设置每组的人数,前往Constants并设置 PLAYERS_PER_GROUP
变量即可。举例来说,对于2人游戏,设置 PLAYERS_PER_GROUP = 2
。
如果所有玩家均在同一组内,或者这是一个单人游戏,将其设为 None
:
每名玩家均有一个 id_in_group
属性,这一属性告诉你这是玩家 1
,玩家 2
,诸如此类。
获得玩家¶
小组对象有下面这些方法:
get_players()¶
返回组内玩家的列表(按照 id_in_group
排序)。
get_player_by_id(n)¶
返回组内具有所指定 id_in_group
的玩家。
获得其他玩家¶
玩家对象有 get_others_in_group()
与 get_others_in_subsession()
方法,分别返回同一小组与子会话中的 其他 玩家的列表。
角色¶
如果每一组中均有多种角色,如买家/卖家,委托人/代理人,诸如此类,你可以将它们定义在常量中。变量名以 _ROLE
结尾:
class C(BaseConstants):
...
PRINCIPAL_ROLE = 'Principal'
AGENT_ROLE = 'Agent
oTree会自动将每一个角色分配给不同的玩家(按照 id_in_group
循序分配)。你可以通过这一点来展示给不同角色不同内容,例如:
class AgentPage(Page):
@staticmethod
def is_displayed(player):
return player.role == C.AGENT_ROLE
在模板中:
You are the {{ player.role }}.
你也可以使用 group.get_player_by_role()
,它类似于 get_player_by_id()
:
def set_payoffs(group):
principal = group.get_player_by_role(C.PRINCIPAL_ROLE)
agent = group.get_player_by_role(C.AGENT_ROLE)
# ...
如果你想要转换玩家的角色,你应当重新分配小组,使用 group.set_players()
,subsession.group_randomly()
等等。
小组配对¶
固定配对¶
默认情况下,每一轮中,玩家被分为数个小组,每组人数为 C.PLAYERS_PER_GROUP
。分组是按照顺序进行的——举例来说,如果2人一组,那么玩家1与玩家2就会被分为一组,然后是玩家3与玩家4被分为一组,以此类推。 id_in_group
也在每组中被顺序分配。
这意味着默认情况下,每一轮中分组是相同的,甚至在不同应用之间均有相同的 PLAYERS_PER_GROUP
。
如果你想重新安排小组,你可以使用下面的技巧。
group_randomly()¶
子会话有一个 group_randomly()
方法可以随机排列玩家,此时玩家被随机分配在任一小组的任一位置。
如果你想要在不同小组间随机安排玩家但保持玩家的角色不变,使用 group_randomly(fixed_id_in_group=True)
。
举例来说,下面的代码会使玩家在每一轮之间随机组队:
def creating_session(subsession):
subsession.group_randomly()
下面的代码让玩家在每一轮之间随机组队,但是保持 id_in_group
不变:
def creating_session(subsession):
subsession.group_randomly(fixed_id_in_group=True)
下面的例子中,假定 PLAYERS_PER_GROUP = 3
,并且会话中共有12名参与者:
def creating_session(subsession):
print(subsession.get_group_matrix()) # outputs the following:
# [[1, 2, 3],
# [4, 5, 6],
# [7, 8, 9],
# [10, 11, 12]]
subsession.group_randomly(fixed_id_in_group=True)
print(subsession.get_group_matrix()) # outputs the following:
# [[1, 8, 12],
# [10, 5, 3],
# [4, 2, 6],
# [7, 11, 9]]
subsession.group_randomly()
print(subsession.get_group_matrix()) # outputs the following:
# [[8, 10, 3],
# [4, 11, 2],
# [9, 1, 6],
# [12, 5, 7]]
group_like_round()¶
在不同轮之间复制小组的结构,可使用 group_like_round(n)
方法。方法中的参数即小组结构被复制的那一轮的轮次。
在下面的例子中,第一轮将随机组队,后面的轮次均复制第一轮的小组结构。
def creating_session(subsession):
if subsession.round_number == 1:
# <some shuffling code here>
else:
subsession.group_like_round(1)
get_group_matrix()¶
子会话有一个方法 get_group_matrix()
,此方法返回小组的结构,以矩阵的形式,例如:
[[1, 3, 5],
[7, 9, 11],
[2, 4, 6],
[8, 10, 12]]
set_group_matrix()¶
set_group_matrix()
让你可以任意改变小组结构。首先使用 get_players()
获得玩家的列表,或者使用 get_group_matrix()
获得已经存在的小组矩阵。构建你想要的矩阵并将其传给 set_group_matrix()
:
def creating_session(subsession):
matrix = subsession.get_group_matrix()
for row in matrix:
row.reverse()
# now the 'matrix' variable looks like this,
# but it hasn't been saved yet!
# [[3, 2, 1],
# [6, 5, 4],
# [9, 8, 7],
# [12, 11, 10]]
# save it
subsession.set_group_matrix(matrix)
你也可以传递一个整数矩阵。整数矩阵中必须包含从1到子会话中的玩家数量的所有整数。每一个整数代表 id_in_subsession
为此整数的玩家。举例如下:
def creating_session(subsession):
new_structure = [[1,3,5], [7,9,11], [2,4,6], [8,10,12]]
subsession.set_group_matrix(new_structure)
print(subsession.get_group_matrix()) # will output this:
# [[1, 3, 5],
# [7, 9, 11],
# [2, 4, 6],
# [8, 10, 12]]
为了检查小组重排是否工作正常,打开浏览器并切换到你的会话的”Results”选项卡,并检查每一轮中的 group
与 id_in_group
两栏是否正确。
group.set_players()¶
此函数类似与 set_group_matrix
,但它仅仅重新排列小组中的玩家,例如你可以使用它来分配给玩家不同的角色。
在会话中重排小组¶
creating_session
通常是重排小组的好地方,但是 creating_session
是在会话创建前运行,即玩家开始行动之前。所以如果你的重排逻辑需要依赖于会话开始后的因素,你应当在等待页面重排。
你需要创建一个 WaitPage
且在其中设定 wait_for_all_groups=True
并将重排的代码放入 after_all_players_arrive
中:
class ShuffleWaitPage(WaitPage):
wait_for_all_groups = True
@staticmethod
def after_all_players_arrive(subsession):
subsession.group_randomly()
# etc...
按照到达时间组队¶
等待页面¶
当一位玩家需要等待其他玩家进行一些操作之后所有玩家才能继续进行游戏时,等待页面是必须的。举例来说,在最后通牒博弈中,玩家2在他看到玩家1的报价之前不能拒绝或者接受这一报价。
如果你在页面序列中放置了一个 WaitPage
,那么oTree会等待小组内的所有玩家都到达了序列中的那一点之后,再允许所有玩家继续下面的游戏。
如果子会话中有多组同时进行游戏,你可能会希望为所有组(也就是子会话中的所有玩家)设置一个等待页面,你可以在等待页面中设置 wait_for_all_groups = True
实现这一点。
获取更多关于小组的信息,参考 小组。
after_all_players_arrive¶
after_all_players_arrive
允许你在所有玩家都到达等待页面之后进行一些计算。这是个设置玩家的收益或者决定获胜者的好地方。你应当首先定义一个小组函数,它实现了你想要的功能。例如:
def set_payoffs(group):
for p in group.get_players():
p.payoff = 100
然后通过下面的方法触发它:
class MyWaitPage(WaitPage):
after_all_players_arrive = set_payoffs
如果你设置了 wait_for_all_groups = True
, 那么 after_all_players_arrive
应当是一个 子会话 函数。
如果你使用的是文本编辑器,after_all_players_arrive
可直接在等待页面中定义:
class MyWaitPage(WaitPage):
@staticmethod
def after_all_players_arrive(group: Group):
for p in group.get_players():
p.payoff = 100
它也可是一个字符串:
class MyWaitPage(WaitPage):
after_all_players_arrive = 'set_payoffs'
is_displayed()¶
与普通页面中的功能相同。
group_by_arrival_time¶
如果你在等待页面设置了 group_by_arrival_time = True
,玩家会按照到达等待页面的顺序组队:
class MyWaitPage(WaitPage):
group_by_arrival_time = True
举例来说,假设 PLAYERS_PER_GROUP = 2
,那么首先到达等待页面的2位玩家将会被分为一组,然后接下来2名玩家一组,以此类推。
这在一些玩家可能会离开的情况下非常有用(例如在线实验或者有允许玩家提前离开的同意页面的实验),或在某些玩家会比其他玩家花费多得多的时间的情况下非常有用。
一种典型的使用 group_by_arrival_time
的方法是将其放在一个应用前面用来筛选参与人。举例来说,如果你的会话中有一个同意页面使得参与人有机会选择退出实验,你可以创建一个“consent”应用,它仅仅包含同意页面,然后 app_sequence
就像 ['consent', 'my_game']
,并且 my_game
中使用 group_by_arrival_time
。这意味着如果有人在 consent
中选择了退出,他们就不会在 my_game
中参与组队。
如果一个游戏有多轮,那么你可能只想在第一轮按照到达时间组队:
class MyWaitPage(WaitPage):
group_by_arrival_time = True
@staticmethod
def is_displayed(player):
return player.round_number == 1
如果你这样做,那么余下的轮次会保持第一轮的小组结构。不然的话,玩家在每一轮都会按照他们的到达时间重新组队。(group_by_arrival_time
复制了小组结构到下面的轮次。)
注意:
- If a participant arrives at the wait page but subsequently switches to a different window or browser tab, they will be excluded from grouping after a short period of time.
id_in_group
并不按照玩家到达页面的顺序被赋值。group_by_arrival_time
仅在等待页面是page_sequence
的第一个页面时可用- 如果你将
is_displayed
与group_by_arrival_time
一起使用,那么它应当仅依赖轮数。不要使用is_displayed
来将页面展示给一部分玩家而不展示给其他玩家。 - 如果
group_by_arrival_time = True
那么在creating_session
时,所有玩家一开始均在同一组中。小组是在玩家到达等待页面的那“一瞬间”被创建的。
如果你需要进一步控制玩家如何组织成小组,参考 group_by_arrival_time_method()。
group_by_arrival_time_method()¶
如果你使用了 group_by_arrival_time
并想要进一步控制哪些玩家被分配到一起,你可以使用 group_by_arrival_time_method()
。
我们假定除了按照到达时间组队之外,你还需要每一组由2名男性和2名女性组成。
如果你定义了一个 group_by_arrival_time_method
函数,它会在每一次新玩家到达等待页面时被执行。这一方法的参数是所有处在本等待页面的玩家组成的列表。如果你选择了其中一些玩家并且返回了他们组成的列表,那么这些玩家会被分为一组,并且继续进行下面的游戏。如果你没有返回任何东西,那么组队不会发生。
下面是一个每一组中有2名男性和2名女性的例子。这里假定了在上一个应用中,你设置了每一位参加者的 participant.category
。
# note: this function goes at the module level, not inside the WaitPage.
def group_by_arrival_time_method(subsession, waiting_players):
print('in group_by_arrival_time_method')
m_players = [p for p in waiting_players if p.participant.category == 'M']
f_players = [p for p in waiting_players if p.participant.category == 'F']
if len(m_players) >= 2 and len(f_players) >= 2:
print('about to create a group')
return [m_players[0], m_players[1], f_players[0], f_players[1]]
print('not enough players yet to create a group')
等待页面上的超时¶
你也可以使用 group_by_arrival_time_method
为等待页面设置一个超时行为,例如允许参与人单独继续进行游戏,如果他们已经等待了超过5分钟。首先,你必须在使用了 group_by_arrival_time
的应用之前的最后一个页面记录 time.time()
。将其存入 participant field。
然后定义一个玩家函数:
def waiting_too_long(player):
participant = player.participant
import time
# assumes you set wait_page_arrival in PARTICIPANT_FIELDS.
return time.time() - participant.wait_page_arrival > 5*60
现在使用下面的代码:
def group_by_arrival_time_method(subsession, waiting_players):
if len(waiting_players) >= 3:
return waiting_players[:3]
for player in waiting_players:
if waiting_too_long(player):
# make a single-player group.
return [player]
这样是可行的,因为等待页面会一分钟自动刷新1次或者2次,此时 group_by_arrival_time_method
就会被重新执行。
避免玩家卡在等待页面¶
一个在线实验中尤其常见的问题是玩家会因为等待一个已经退出或者太慢的玩家而卡住。
下面是一些减少此问题的方法:
使用 group_by_arrival_time
¶
正如上文所述,你可以使用 group_by_arrival_time
使得那些大概同时完成页面的玩家在一起组队。
如果 group_by_arrival_time
放在一个“锁定”任务之后,它的效果会很好。换句话说,在你的多人游戏之前,你可以先有一个单人任务。这里的想法是如果一位参与人花了力气完成了第一个任务,那么他就有更少的可能在之后退出。
使用页面超时¶
在每个页面上使用 timeout_seconds ,这样当一位玩家很慢或者不再操作,他的页面会自动继续进行下去。或者,你可以手动强制超时,通过点击admin界面的 “Advance slowest participants” 按钮。
检查超时发生¶
你可以告知用户他们必须在 timeout_seconds
之前提交页面,否则就会被算作退出游戏。甚至可以添加一个页面,仅仅询问“点击继续按钮以确认继续进行游戏”。然后检查 timeout_happened。如果其值为True,你可以做一些事情如设置这位玩家/这个小组的某个字段以标记其已经退出,并跳过本轮余下的页面。
将退出的玩家替换为bot¶
下面是一个组合使用了上面一些技巧的例子,此时即使某位玩家退出了,他仍可以继续自动游戏,就如机器人一样。首先定义一个 participant field 命名为 is_dropout
,并将其初始值在 creating_session
中设为 False
。即像下面这样在每一个页面上使用 get_timeout_seconds
与 before_next_page
:
class Page1(Page):
form_model = 'player'
form_fields = ['contribution']
@staticmethod
def get_timeout_seconds(player):
participant = player.participant
if participant.is_dropout:
return 1 # instant timeout, 1 second
else:
return 5*60
@staticmethod
def before_next_page(player, timeout_happened):
participant = player.participant
if timeout_happened:
player.contribution = cu(100)
participant.is_dropout = True
注意:
- 如果玩家未能按时提交页面,即将
is_dropout
设为True
。 - 一旦
is_dropout
被设置,每个页面都会被立即自动提交。 - 当页面被自动提交时,可使用
timeout_happened
来代替用户决定所要提交的值。
聊天¶
你可以在页面上添加一个聊天室,使得参与者之间可以互相交流。
定制昵称与聊天室成员¶
你可以像下面这样指定 channel
和/或 nickname
:
{{ chat nickname="abc" channel="123" }}
昵称¶
nickname
即用户在聊天时显示的昵称。一种典型的用法是 {{ chat nickname=player.role }}
。
频道¶
channel
是聊天室的名字,这意味着如果2名玩家在同一 channel
内,那么他们可以互相聊天。 channel
并不会在用户界面上显示;它是在内部使用的。它的默认值是 group.id
,表示所有同一组的玩家都可以一起聊天。你可以使用 channel
将聊天的范围限制为当前的页面或者组中的划分等等(参考下面的例子)。无论 channel
的值如何,聊天的范围至少在同一应用的同一会话的玩家内。
例子:基于角色聊天¶
下面是一个在组间基于角色聊天,而不是在组内聊天的例子。例如所有的买家可以相互交流,所有的卖家可以相互交流。
def chat_nickname(player):
group = player.group
return 'Group {} player {}'.format(group.id_in_subsession, player.id_in_group)
在页面中:
class MyPage(Page):
@staticmethod
def vars_for_template(player):
return dict(
nickname=chat_nickname(player)
)
然后在模板中:
{{ chat nickname=nickname channel=player.id_in_group }}
高级自定义¶
如果你在浏览器中查看了页面的源码,你会发现一系列以 otree-chat__
开头的类。
你可以使用CSS或JS改变这些元素的外观或者行为(或者将它们完全隐藏起来)。
你也可以通过将其放入一个 <div>
并调整这个父 <div>
的样式来定制外观。例如像下面这样设置宽度:
<div style="width: 400px">
{{ chat }}
</div>
同一页面上多个聊天¶
你可以在同一页面上放置多个 {{ chat }}
,这样一名玩家就可以同时处于多个频道中。
应用&轮次¶
应用¶
一个oTree应用就是一个包含Python与HTML代码的文件夹。一个项目包含多个应用。一个会话基本上就是一个接一个被游玩的应用序列。
组合应用¶
你可以通过设置session config的 'app_sequence'
来组合应用。
在应用之间传递数据¶
轮次¶
你可以通过设置 C.NUM_ROUNDS
来使一个游戏进行多轮。举例来说,如果你的session config中的 app_sequence
是 ['app1', 'app2']
,同时 app1
中 NUM_ROUNDS = 3
且 app2
中 NUM_ROUNDS = 1
,那么你的会话中就包含4个子会话。
轮数¶
你可以通过 player.round_number
(目前subsession,group,player 与 page 对象有此属性)来获得当前的轮数。轮数从1开始。
在应用或轮次之间传递数据¶
每一轮都拥有独立的subsession, Group
, 与 Player
对象。举例来说,假定你在第一轮中设定了 player.my_field = True
。在第二轮时,你试图访问 player.my_field
,你会发现它的值是 None
。这是因为第一轮的 Player
对象与第二轮的 Player
对象是独立的。
为了获得之前轮次或者应用的数据,你可以使用下面描述的这些技巧。
in_rounds, in_previous_rounds, in_round, etc.¶
Player,group 与 subsession 对象有下面这些方法:
- in_previous_rounds()
- in_all_rounds()
- in_rounds()
- in_round()
举例来说,如果你在一个10轮游戏的最后一轮, player.in_previous_rounds()
会返回一个由9个player对象组成的列表,就代表了当前参与者在之前轮次的对象。
player.in_all_rounds()
几乎与上面相同,只不过列表中有10个对象,因为它包含了当前轮次的player对象。
player.in_rounds(m, n)
返回一个player对象的列表,其中代表了同一参与人第 m
到 n
轮的player对象。
player.in_round(m)
返回第 m
轮的player对象。举例来说,要获得玩家前一轮的收益,可用:
prev_player = player.in_round(player.round_number - 1)
print(prev_player.payoff)
这些方法对于subsession也是相同的(例如 subsession.in_all_rounds()
)。
这些方法对于group也是相同的,但是如果你在轮次之间重新安排了小组,那么使用这些方法是没有意义的。
参与人字段¶
如果你想要获取一位参与人在之前应用中的数据,你应当将数据存储在参与人对象中,这样数据可以在不同应用间保持。(参考 参与人)。(in_all_rounds()
仅在你需要获取同一应用中的之前轮次的数据时有用。)
设置并定义 PARTICIPANT_FIELDS
,它是一个列表,包含了你想要在参与人上定义的字段名称。
在代码中,你可以取出或设置这些字段上的任何类型的数据:
participant.mylist = [1, 2, 3]
(在内部,所有参与人字段均存储在一个名为 participant.vars
的字典中。participant.xyz
等价于 participant.vars['xyz']
。)
会话字段¶
对于会话中所有参与人都相同的全局变量,可将其添加到 SESSION_FIELDS
中,这与 PARTICIPANT_FIELDS
用法相同。在内部,会话字段储存在 session.vars
中。
可变轮数¶
如果你想要可变的轮数,考虑使用 实时页面。
作为替代方案,你可以将 NUM_ROUNDS
设为一个较大的数,然后在应用中,有条件地隐藏 {{ next_button }}
元素,使得用户无法继续进行到下一页面,或者使用 app_after_this_page。 但是注意轮数较大(例如超过100)可能会导致性能问题,所以请仔细测试你的程序。
实验组¶
为将参与人分配至不同的实验组,你可以使用 creating_session
。举例来说:
def creating_session(subsession):
import random
for player in subsession.get_players():
player.time_pressure = random.choice([True, False])
print('set time_pressure to', player.time_pressure)
你也可以将实验组设为小组级别(将 BooleanField
加入 Group
并将上面的代码改为使用 get_groups()
与 group.time_pressure
)。
creating_session
会在你点击”create session”按钮后立刻执行,即使应用不在 app_sequence
的首位。
实验组与多轮次¶
如果你的游戏有很多轮,一位玩家可以在不同轮处于不同实验组,因为 creating_session
会在每一轮独立地执行。为了避免这种情况,将其设置在参与人上,而不是玩家上:
def creating_session(subsession):
if subsession.round_number == 1:
for player in subsession.get_players():
participant = player.participant
participant.time_pressure = random.choice([True, False])
平衡实验组¶
上面的代码对每个玩家都进行随机分配,故你最终可能得到一个不平衡的分组。为解决此问题,你可以使用 itertools.cycle
:
def creating_session(subsession):
import itertools
pressures = itertools.cycle([True, False])
for player in subsession.get_players():
player.time_pressure = next(pressures)
选择特定实验组进行游戏¶
在实际实验中,你一般会随机将玩家分配到实验组。但是当你测试你的游戏时,常常需要显式指定某一实验组进行游戏。假定你在基于上面的例子开发游戏,并想向你的同学展示两个实验组。你可以创建2个除了某一参数外完全相同的session config(在oTree Studio中,添加一个”custom parameter”):
SESSION_CONFIGS = [
dict(
name='my_game_primed',
app_sequence=['my_game'],
num_demo_participants=1,
time_pressure=True,
),
dict(
name='my_game_noprime',
app_sequence=['my_game'],
num_demo_participants=1,
time_pressure=False,
),
]
然后在你的代码中,你可以通过下面的方法获当前会话的实验组:
session.config['time_pressure']
你甚至可以将此与随机化方法结合起来。你可以检查 if 'time_pressure' in session.config:
;如果是,那就使用它;如果否,就随机选择一个。
配置会话¶
你可以使你的会话可配置,这样你就可以在管理员界面调整游戏的参数。

举例来说,假定你有一个“num_apples”参数。通常的方法是将其定义在 C
中,如 C.NUM_APPLES
。但是要使其可配置,你可以将其定义在你的session config中。例如:
dict(
name='my_session_config',
display_name='My Session Config',
num_demo_participants=2,
app_sequence=['my_app_1', 'my_app_2'],
num_apples=10
),
当你在管理员界面创建会话的时候,就会有一个文本框可改变这一值。你也可以使用 'doc'
添加帮助信息:
dict(
name='my_session_config',
display_name='My Session Config',
num_demo_participants=2,
app_sequence=['my_app_1', 'my_app_2'],
num_apples=10,
doc="""
Edit the 'num_apples' parameter to change the factor by which
contributions to the group are multiplied.
"""
),
在你的代码中,可使用 session.config['num_apples']
。
注意:
- 对于一个可配置的参数,它的值必须是数值,布尔类型或字符串。
- 在管理员界面的“Demo”部分,会话是不可配置的。它仅在 “Sessions” 或 “Rooms” 中创建会话时可用。
超时¶
基本用法¶
timeout_seconds¶
为了给你的页面设置一个时间限制,添加 timeout_seconds
:
class Page1(Page):
timeout_seconds = 60
在时间耗尽之后,页面会自动提交。
如果你在生产服务器(prodserver
)上运行,那么页面总会被提交,即使用户关掉了浏览器。然而,如果你在开发服务器上(zipserver
or devserver
)运行,这将不会发生。
如果你需要超时能够被动态决定,使用 get_timeout_seconds。
timeout_happened¶
你可以检查页面是否是因超时而提交的:
class Page1(Page):
form_model = 'player'
form_fields = ['xyz']
timeout_seconds = 60
@staticmethod
def before_next_page(player, timeout_happened):
if timeout_happened:
# you may want to fill a default value for any form fields,
# because otherwise they may be left null.
player.xyz = False
get_timeout_seconds¶
有一种更加灵活的替代 timeout_seconds
的方法,使得你可以让超时取决于 player
,player.session
,等等。
举例来说:
class MyPage(Page):
@staticmethod
def get_timeout_seconds(player):
return player.my_page_timeout_seconds
或者使用定制的session config参数 (参见 选择特定实验组进行游戏)。
def get_timeout_seconds(player):
session = player.session
return session.config['my_page_timeout_seconds']
高级技巧¶
因超时提交的表单¶
如果表单由于超时被自动提交,oTree会试着保存表单在提交时字段所填入的内容。如果表单中的字段中有一个错误如缺失或非法,oTree会自动对数值字段填入 0
,布尔字段填入 False
,字符串字段填入 ''
。
如果你想要舍弃这些自动提交的值,你可以检查 timeout_happened
,如果需要的话,你可以覆写这些值。
如果 error_message()
函数失败了,那么整个表单可能是非法的,故整个表单都会被舍弃。
跨越多个页面的超时¶
你可以使用 get_timeout_seconds
来创建跨越多个页面甚至是整个会话的超时。技巧是定义一个固定的”到期时间”,然后在每个页面上,让 get_timeout_seconds
返回当前时间到到期时间的秒数。
首先,选择开始计时器的地方。这可以是一个名为”开始”的页面,显示如”准备好开始后按下按钮”的文本。当用户点击”下一页”按钮后, before_next_page
会被执行:
class Start(Page):
@staticmethod
def before_next_page(player, timeout_happened):
participant = player.participant
import time
# remember to add 'expiry' to PARTICIPANT_FIELDS.
participant.expiry = time.time() + 5*60
(你也可以在 after_all_players_arrive
或 creating_session
中启动计时器,并将到期时间存储在 会话字段中,如果它对会话中的所有人都相同的话。)
然后,每个页面的 get_timeout_seconds
应当为直到到期时间的秒数:
class Page1(Page):
@staticmethod
def get_timeout_seconds(player):
participant = player.participant
import time
return participant.expiry - time.time()
当时间耗尽后, get_timeout_seconds
会返回0或者一个负数,这会导致页面立即被加载并自动提交。这意味着所有剩余页面会快速闪过参与者的屏幕,这通常不是想要的结果。所以你应该使用 is_displayed
来跳过这些页面,如果实际上参与者已经没有时间阅读这些页面。
def get_timeout_seconds(player):
participant = player.participant
import time
return participant.expiry - time.time()
class Page1(Page):
get_timeout_seconds = get_timeout_seconds
@staticmethod
def is_displayed(player):
return get_timeout_seconds(player) > 3
计时器的默认文本是”完成此页面的剩余时间为:”。但是如果你的计时器是跨越多个页面的,你应当通过设定 timer_text
让其表达得更准确一些:
class Page1(Page):
timer_text = 'Time left to complete this section:'
@staticmethod
def get_timeout_seconds(player):
...
自定义计时器¶
改变计时器的行为¶
计时器的功能由 jQuery Countdown 提供。你可以通过jQuery的 .on()
和 off()
添加和去除事件处理器来改变它的行为。
oTree为 update.countdown
和 finish.countdown
设置了事件处理器,故如果你想要更改它们,你可以使用 off()
解除它们,并/或通过 on()
添加你自己的事件处理器。倒计时元素为 .otree-timer__time-left
。
例如,隐藏计时器直到还剩10秒,
<style>
.otree-timer {
display: none;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function (event) {
$('.otree-timer__time-left').on('update.countdown', function (event) {
if (event.offset.totalSeconds === 10) {
$('.otree-timer').show();
}
});
});
</script>
为了避免将此代码复制到每一个页面上,将其放在一个可包含的模板中。
Note: even if you turn off the finish.countdown
event handler,
the page will still be submitted on the server side.
So, instead you should use the technique described in 不会提交页面的超时.
不会提交页面的超时¶
如果你只是想用温和的超时,那么你根本不需要内置的计时器。你应该使用JavaScript创建自己的超时,例如:
setTimeout(
function () {
alert("Time has run out. Please make your decision.");
},
60*1000 // 60 seconds
);
机器人¶
机器人模拟参与人游玩你的应用。它们点击按钮,填写表单,确保一切运行正常。
这一特性是为那些希望oTree能够自动测试他们应用的懒人设计的。并且oTree Studio甚至可以为你设计模拟代码,使得整个过程(编写和运行机器人程序)变得非常容易。
运行bots¶
- 将bots添加到你的应用中(参考下面的代码)
- 在你的session config中,设置
use_browser_bots=True
。 - 运行服务器并创建一个会话。一旦开始链接被打开,页面会使用浏览器bots自动进行。
编写测试¶
在oTree Studio中,前往你应用的“Test”部分。单击按钮以自动编写bots代码。如果你想要改进生成的代码(比如加入 expect()
语句),阅读下面的部分。
如果你使用的是文本编辑器,前往 tests.py
。参考 这里 的例子来了解如何定义 tests.py
。
提交页面¶
你应当对每一个页面子任务创建一个 yield
,例如:
yield pages.Start
yield pages.Survey, dict(name="Bob", age=20)
这里,我们首先提交 Start
页面,它不包含任何表单。下一个页面有2个表单字段,故我们提交一个字典。
测试系统会抛出一个错误如果机器人提交了不正确的输入,或是按照错误的顺序提交了页面。
你可以使用 if
语句来判断角色或者轮数。举例如下:
if self.round_number == 1:
yield pages.Introduction
if self.player.id_in_group == 1:
yield pages.Offer, dict(offer=30)
else:
yield pages.Accept, dict(offer_accepted=True)
你的 if
语句可以依赖 self.player
,self.group
,self.round_number
,诸如此类。
编写bots时请忽略等待页面。
轮次¶
你的bot代码应当每次仅模拟一轮。oTree会自动将其执行 NUM_ROUNDS
次。
expect()¶
你可以使用 expect
语句来确保你的代码如你期望地那样工作。
例如:
expect(self.player.num_apples, 100)
yield pages.Eat, dict(apples_eaten=1)
expect(self.player.num_apples, 99)
yield pages.SomeOtherPage
如果 self.player.num_apples
不是99,那么你就会被提示有一个错误。
你也可以在expect中使用3个参数,如 expect(self.player.budget, '<', 100)
。这会验证 self.player.budget
比100小。你可以使用下面的这些运算符: '<'
,'<='
,'=='
,'>='
,'>'
,'!='
,'in'
,'not in'
。
测试表单验证¶
如果你使用了 form validation,你应当测试你的应用是否正确地防止了来自用户的错误输入,测试可通过 SubmissionMustFail()
进行。
例如你的页面如下:
class MyPage(Page):
form_model = 'player'
form_fields = ['int1', 'int2']
@staticmethod
def error_message(player, values):
if values["int1"] + values["int2"] != 100:
return 'The numbers must add up to 100'
下面的代码测试了它是否正常工作:
yield SubmissionMustFail(pages.MyPage, dict(int1=99, int2=0))
yield pages.MyPage, dict(int1=99, int2=1)
Bot会提交 MyPage
两次。如果第一次提交 成功,那么一个错误会被抛出,因为这本不应该成功。
检查HTML¶
self.html
包含了你所要提交页面的HTML。你可以将其与 expect()
一起使用:
if self.player.id_in_group == 1:
expect(self.player.is_winner, True)
print(self.html)
expect('you won the game', 'in', self.html)
else:
expect(self.player.is_winner, False)
expect('you did not win', 'in', self.html)
yield pages.Results
# etc...
self.html
在每一个 yield
语句之后被更新为下一个页面的HTML。换行符与多余的空格会被忽略。
自动HTML检查¶
如果bot试图提交一个在页面HTML实际上不存在的表单或者页面HTML缺少了提交按钮,一个错误会被抛出。
然而,bot系统并不能识别被JavaScript动态添加的字段和按钮。在这种情况下,你应当禁用HTML检查,即设置 check_html=False
。例如,修改下面的代码:
yield pages.MyPage, dict(foo=99)
为:
yield Submission(pages.MyPage, dict(foo=99), check_html=False)
(如果你在 Submission
中不设置 check_html=False
,那么两段代码是等价的。)
模拟页面超时¶
你可以在 Submission
中设置 timeout_happened=True
:
yield Submission(pages.MyPage, dict(foo=99), timeout_happened=True)
高级特性¶
参见 Bots: 高级特性
实时页面¶
实时页面与服务器持续通讯并实时更新,让实时游戏成为可能。实时页面对于多位用户间存在很多交互或单人快速重复的游戏非常适合。
这里 有很多例子。
向服务器发送数据¶
在你模板的JavaScript代码中,调用 liveSend()
函数来向服务器发送数据。例如,代表用户提交一个值为99的出价:
liveSend(99);
定义一个函数接收此消息。它的参数就是所发送的数据。
class MyPage(Page):
@staticmethod
def live_method(player, data):
print('received a bid from', player.id_in_group, ':', data)
如果你使用了 oTree Studio,你必须定义一个函数名以 live_
开头的玩家函数。(注意, WaitPage
上的 live_method
尚未被支持。)
向页面发送数据¶
为将数据发回,返回一个字典,键为玩家的ID以收到消息。例如,下面是一个简单的方法,发送“thanks”给任何发送消息的人。
def live_method(player, data):
return {player.id_in_group: 'thanks'}
为向多位玩家发送消息,使用他们的 id_in_group
作为键即可。举例来说,下面的函数将消息发送给玩家2和3:
def live_method(player, data):
return {2: data, 3: data}
向全组进行广播,使用 0
作为键(这是特殊情况,不是某个 id_in_group
)。
def live_method(player, data):
return {0: data}
在你的JavaScript代码中,定义函数 liveRecv
。此函数在每次收到服务器的消息时会被自动调用。
function liveRecv(data) {
console.log('received a message!', data);
// your code goes here
}
例子:拍卖¶
class Group(BaseGroup):
highest_bidder = models.IntegerField()
highest_bid = models.CurrencyField(initial=0)
class Player(BasePlayer):
pass
def live_method(player, bid):
group = player.group
my_id = player.id_in_group
if bid > group.highest_bid:
group.highest_bid = bid
group.highest_bidder = my_id
response = dict(id_in_group=my_id, bid=bid)
return {0: response}
<table id="history" class="table">
<tr>
<th>Player</th>
<th>Bid</th>
</tr>
</table>
<input id="inputbox" type="number">
<button type="button" onclick="sendValue()">Send</button>
<script>
let history = document.getElementById('history');
let inputbox = document.getElementById('inputbox');
function liveRecv(data) {
history.innerHTML += '<tr><td>' + data.id_in_group + '</td><td>' + data.bid + '</td></tr>';
}
function sendValue() {
liveSend(parseInt(inputbox.value));
}
</script>
(注意,在JavaScript中 data.id_in_group == data['id_in_group']
。)
数据¶
你所发送和接受的数据可以是任意数据类型(只要其能够被JSON序列化)。举例来说下面均是合法的:
liveSend(99);
liveSend('hello world');
liveSend([4, 5, 6]);
liveSend({'type': 'bid', 'value': 10.5});
最多用途的数据类型是字典,它可以让你包含多种元数据,尤其是消息的类型:
liveSend({'type': 'offer', 'value': 99.9, 'to': 3})
liveSend({'type': 'response', 'accepted': true, 'to': 3})
此时你就可以用 if
语句来处理不同种类的消息:
def live_method(player, data):
t = data['type']
if t == 'offer':
other_player = data['to']
response = {
'type': 'offer',
'from': player.id_in_group,
'value': data['value']
}
return {other_player: response}
if t == 'response':
# etc
...
历史¶
默认情况下,参与者不会看见他们到达页面之前发送的消息。(并且如果刷新页面数据并不会重新发送。)如果你希望保存历史,你应当将其存储在数据库中。当玩家加载页面时,你的JavaScript可以调用像 liveSend({})
的东西,并且你可以配置你自己的实时方法来从数据库中提取游戏的历史。
附加模型¶
实时页面常与 ExtraModel 一同使用,这使得你可将每条独立的消息或行为存入数据库中。
让用户停留在页面上¶
假定玩家在进行下一个页面之前你需要先发送10条消息。
首先,将 {{ next_button }}
删除。(或使用JS将其隐藏直到工作完成。)
当信息发送完成后,发送一条消息:
class Group(BaseGroup):
num_messages = models.IntegerField()
game_finished = models.BooleanField()
class MyPage(Page):
def live_method(player, data):
group = player.group
group.num_messages += 1
if group.num_messages >= 10:
group.game_finished = True
response = dict(type='game_finished')
return {0: response}
然后在模板中,通过JavaScript自动提交页面:
function liveRecv(data) {
console.log('received', data);
let type = data.type;
if (type === 'game_finished') {
document.getElementById("form").submit();
}
// handle other types of messages here..
}
顺带一提,使用相同的技巧,你可以实现一个自定义的等待页面,例如在某个时间之后才会进行下一页面,即使所有玩家均已到达的等待页面。
关于实时页面的常规建议¶
下面是一些常规建议(并不适用于所有情况)。我们推荐使用Python实现大多数逻辑,并只使用JavaScript来更新页面的HTML,因为:
- 正确使用JavaScript可能是十分具有技巧性的
- 你的Python代码运行在服务器上,是中心化和可靠的。JavaScript运行在客户端上,可能会与其他客户端失去同步,而且数据在页面关闭或者重载的情况下可能会丢失。
- 由于Python代码是运行在服务器上的,故它更加安全并不会被参与人看到或修改。
例子:井字棋¶
假定你想要实现一个井字棋游戏。你的live_method可能会收到两种消息:
- 一位用户标记了一个方格,故你需要通知其他玩家
- 一位用户载入(或重载)了页面,故你需要将当前的局面发给他们。
对于情况1,你应当使用JavaScript事件处理器如 onclick
,例如当用户点击方格3时,此操作会被发送给服务器:
liveSend({square: 3});
对于情况2,推荐将类似下面的代码放入你的模板中,以在页面载入时发送一条空消息到服务器:
document.addEventListener("DOMContentLoaded", (event) => {
liveSend({});
});
服务器通过“if”语句处理这两种情况:
def live_method(player, data):
group = player.group
if 'square' in data:
# SITUATION 1
square = data['square']
# save_move should save the move into a group field.
# for example, if player 1 modifies square 3,
# that changes group.board from 'X O XX O' to 'X OOXX O'
save_move(group, square, player.id_in_group)
# so that we can highlight the square (and maybe say who made the move)
news = {'square': square, 'id_in_group': player.id_in_group}
else:
# SITUATION 2
news = {}
# get_state should contain the current state of the game, for example:
# {'board': 'X O XX O', 'whose_turn': 2}
payload = get_state(group)
# .update just combines 2 dicts
payload.update(news)
return {0: payload}
情况2中(用户载入页面),客户端获得一条类似下面的消息:
{'board': 'X OOXX O', 'whose_turn': 2}
情况1中,玩家获得其他玩家刚刚进行的操作,与当前的局面。
{'board': 'X OOXX O', 'whose_turn': 2, 'square': square, 'id_in_group': player.id_in_group}
JavaScript代码是“被动”的。它不需要追踪是谁进行了操作;它只是相信从服务器收到的信息。它甚至可以在每次收到一条消息的时候重绘界面。
你的代码需要验证用户输入。例如,如果玩家1在属于玩家2的回合行动,你就需要屏蔽这样的操作。出于之前列出的原因,更推荐将此逻辑放在你的live_method中,而不是在JavaScript代码中。
小结¶
正如上面说明的,live_method的典型模式如下:
if the user made an action:
state = (get the current state of the game)
if (action is illegal/invalid):
return
update the models based on the move.
news = (produce the feedback to send back to the user, or onward to other users)
else:
news = (nothing)
state = (get the current state of the game)
payload = (state combined with news)
return payload
注意到我们两次获取了游戏的状态。这是因为当我们更新模型的时候状态发生了改变,故我们需要刷新它。
故障排除¶
如果你在页面载入完成之前调用了 liveSend
,你会得到一个错误如 liveSend is not defined
。所以先等待 DOMContentLoaded
(或jQuery document.ready,等等):
window.addEventListener('DOMContentLoaded', (event) => {
// your code goes here...
});
不要让用户点击“下一页”按钮时触发 liveSend
,因为离开页面可能会打断 liveSend
。相反的,应当让用户点击一个普通按钮时触发 liveSend
,然后在 liveRecv
中使用 document.getElementById("form").submit();
。
服务器设置¶
如果你只是想在你的电脑上测试你的应用,你可以使用 otree devserver
。你不需要完整的服务器设置。
然而,如果你希望与他人分享你的应用,你必须使用web服务器。
选择你需要的选项:
你想要在网络上向用户发布你的应用
使用 Heroku。
你想要最简单的设置
我们仍然推荐 Heroku。
你想要设置一个Linux专用服务器
Ubuntu Linux 说明。
基本服务器设置(Heroku)¶
Heroku 是一家商业云主机提供商。这是部署oTree的最简单的方法。
Heroku的免费服务器足够测试应用使用,但一旦你准备开始真正的实验,你应当将其升级为付费服务器,这样可以处理更多的通信。不过,Heroku并不昂贵,因为你只需要为你实际使用的时间付费。如果你的实验只持续1天,你可以在其他时候关闭你的服务器和附加服务,这样你只需支付月费的1/30。通常这意味着你可以花几美元做一次实验。
Heroku设置¶
To deploy to Heroku, you should use oTree Hub, which automates your server setup and ensures your server is correctly configured.
oTree Hub 还提供错误/性能监控和Sentry服务。
服务器性能¶
Heroku提供不同的性能等级的资源,如服务器和数据库。你需要何种等级的性能取决于你应用的通信流量以及它是如何编写的。
性能是很复杂的事情因为其影响因素众多。 oTree Hub的Pro订阅提供一个”monitor”模块,用来分析你的日志以发现性能问题。
一般的建议:
- 将oTree升级至最新版本
- 使用浏览器bot来对你的应用进行压力测试。
- 在较高等级服务器中,Heroku提供一个”Metrics”页面。可查看”Dyno load”。如果用户认为页面加载速度慢并且你的dyno load持续大于1,那么你应当使用更快的服务器。(但不要运行超过1个web服务器。)
- 如果你的dyno load持续小于1但是页面加载时间仍很慢,那么瓶颈可能是其他因素如Postgres数据库。
性能要求最高的场景是下面这些的组合 (1) 多个轮次, (2) 玩家在每个页面上仅花费几秒时间, (3) 多个玩家同时进行游戏,因为这些场景会导致每秒页面请求数很高,这会使服务器过载。考虑使用 实时页面 改写这些游戏,这会使得性能大幅提高。
Ubuntu Linux 服务器¶
We typically recommend newcomers to oTree to deploy to Heroku (see instructions here).
然而,你可能更想将oTree运行在一个适当的Linux服务器上。原因可能包括:
- 你的实验室没有网络
- 你想要完全控制服务器的配置
- 你想要更好的性能(局域网服务器有更低的延迟)
创建一个virtualenv¶
使用virtualenv的最佳实践是:
python3 -m venv venv_otree
为在每次打开shell时激活此venv,将此放到你的 .bashrc
或 .profile
中:
source ~/venv_otree/bin/activate
一旦你的virtualenv被激活,你会看见 (venv_otree)
显示在命令行的开头。
数据库 (Postgres)¶
安装Postgres 与 psycopg2,创建一个新数据库并设置 DATABASE_URL
环境变量,例如设为 postgres://postgres@localhost/django_db
运行服务器¶
测试生产服务器¶
在你的项目文件夹,运行:
otree prodserver 8000
然后在浏览器中导航至你的服务器的IP/主机名并以 :8000
结尾。
如果你没有使用反向代理如Nginx 或 Apache,你可能想直接将oTree运行在80端口上。这需要超级管理员权限,所以要使用sudo,但需添加一下额外的参数来保存环境变量如 PATH
,DATABASE_URL
,等等:
sudo -E env "PATH=$PATH" otree prodserver 80
再打开你的浏览器;这一次,你无需在URL后添加 :80 ,因为这是HTTP默认的端口。
区别于 devserver
,prodserver
不会在你的文件改变时自动重启。
设置其余的环境变量¶
将下面这些添加到与你设置 DATABASE_URL
相同的位置:
export OTREE_ADMIN_PASSWORD=my_password
#export OTREE_PRODUCTION=1 # uncomment this line to enable production mode
export OTREE_AUTH_LEVEL=DEMO
(可选) 进程控制系统¶
一旦服务器如上正常工作,使用如 Supervisord 或 Circus 这样的进程管理系统是一个好的实践。这会自动重启你的进程无论它是如何崩溃的,并在你登出时保持运行,等等。
Circus¶
安装 Circus,然后在你的项目文件夹创建一个 circus.ini
,内容如下:
[watcher:webapp]
cmd = otree
args = prodserver 80
use_sockets = True
copy_env = True
然后运行:
sudo -E env "PATH=$PATH" circusd circus.ini
如果它工作正常,你可以将其作为守护进程启动:
sudo -E env "PATH=$PATH" circusd --daemon circus.ini --log-output=circus-logs.txt
要停止circus,运行:
circusctl stop
(可选) Apache, Nginx, 等等¶
你不能使用 Apache 或 Nginx 作为你的首选web服务器,因为oTree必须使用ASGI服务器运行。然而,你仍可以使用Apache/Nginx作为反向代理,出于下面这些原因:
- 你试图优化静态文件的速度(尽管oTree使用了Whitenoise,已经相当高效率了)
- 你需要在同一台服务器上部署其他网站
- 你需要类似SSL或代理缓存的特性
如果你设置了反向代理,确保其不仅代理了HTTP通信还代理了websocket。
故障排除¶
如果你遇到了奇怪的程序行为,如每次页面重载时会随机变化,这可能是另一个未被关闭的oTree实例导致的。试着停止oTree并再一次重载。
与其他oTree用户共享服务器¶
你可以与其他oTree用户共享服务器;你只需确保代码与数据库保持隔离,这样就不会相互冲突。
在服务器上你应当为每一个使用oTree的人创建一个不同的Unix用户。然后每个人应当按照上面所描述的同样的步骤操作,但是在某些情况下应当使用不同的名字以避免冲突:
- 在home目录下创建一个virtualenv
- 创建一个不同的Postgres数据库,如之前所述,并将此设置在DATABASE_URL环境变量中。
一旦完成这些步骤,第二位用户就可以将代码推送至服务器,并运行 otree resetdb
。
如果不需要多人同时运行实验,那么每位用户可以轮流将服务器运行在80端口上,使用 otree prodserver 80
命令。然而,如果多人需要同时运行实验,那么你需要将服务器运行在不同的端口上,例如 8000
,8001
,等等。
Windows服务器(高级)¶
如果你只是在你的个人电脑上测试你的应用,你可以使用 otree zipserver
或 otree devserver
。你不需要上述完整的服务器设置,但这对与他人分享你的应用是必须的。
此部分适用于对设置web服务器有经验的人。如果你想要更简单和快速的方式,我们推荐使用 Heroku。
管理员¶
oTree的管理员界面允许你创建会话,监控会话,并从会话中导出数据。
打开你的浏览器,网址为 localhost:8000
或你的服务器的URL。
密码保护¶
当你首次安装oTree后,整个管理员界面是不需要密码访问的。然而,当你准备向用户部署时,你应当用密码保护管理员界面。
如果你发起实验时想让访问者仅可通过所提供的开始链接进行游戏,将环境变量 OTREE_AUTH_LEVEL
设为 STUDY
。
将你的网站上线并设为公开demo模式,也就是说任何人都可以游玩你游戏的demo版本(但不能完全访问管理员界面),设置 OTREE_AUTH_LEVEL
为 DEMO
。
通常管理员的用户名是”admin”。你应当在环境变量 OTREE_ADMIN_PASSWORD
中设置你的密码(在Heroku中,登录到你的Heroku仪表盘,并将其定义为一个配置变量(config var))。
如果你修改了管理员的用户名或者密码,你需要重置数据库。
参与人标签¶
不管使用 room 与否,你可以给每位参与人的开始链接添加 participant_label
参数来标识他们,比如通过名字,ID号码,电脑等。例如:
http://localhost:8000/room/my_room_name/?participant_label=John
oTree会记录下这个参与人标签。它会被用来在oTree管理员界面和报酬页面等地方标识参与者身份。你也可以在你的代码中通过 participant.label
来访问它。
另一个使用参与人标签的好处是如果参与人打开了参与链接两次,他们会被分配为同一个参与者(如果你使用了房间链接或者会话链接)。这减少了重复参与。
到达顺序¶
oTree会将第一个到达的人分配为P1,第二个分配为P2,以此类推,除非你使用了单次使用链接。
定制管理员界面(管理员报告)¶
你可以在会话的管理员界面上添加一个自定义标签,放入任何你想要的内容。例如:
- 玩家结果的表格/图
- 与oTree内置不同的定制的报酬页面
下面是屏幕截图:

下面是一个简单的例子,我们添加一个管理员报告,它展示了某一给定轮次的排序过的收益列表。
首先,定义一个函数 vars_for_admin_report
。这与 vars_for_template() 原理类似。例如:
def vars_for_admin_report(subsession):
payoffs = sorted([p.payoff for p in subsession.get_players()])
return dict(payoffs=payoffs)
然后在你的应用中创建一个可包含的模板 admin_report.html
,并展示任何你想展示的通过 vars_for_admin_report
传入的变量:
<p>Here is the sorted list of payoffs in round {{ subsession.round_number }}</p>
<ul>
{{ for payoff in payoffs }}
<li>{{ payoff }}</li>
{{ endfor }}
</ul>
注意:
subsession
,session
与C
会被自动传递给模板。admin_report.html
不需要使用{{ block }}
。上面的例子包含了admin_report.html
完整有效的内容。
如果你会话中有一个或多个应用具有 admin_report.html
,你的管理员页面就会有 “Reports” 选项卡。使用菜单选择应用与轮数,以查看特定子会话的报告。
导出数据¶
在管理员界面,点击“Data”来下载你的数据,以CSV或者Excel的形式。
有一种数据导出是为”页面时间“准备的,它保存了用户完成每一个页面的精确时间。 这
是一个Python脚本,你可以用它列出每一页分别花了多长时间。你可以修改这个脚本来计算类似的事情,比如每位参与人在等待页面上一共花了多长时间。
自定义数据导出¶
你可以在应用中自定义数据导出。在oTree Studio中,前往 “Player” 模块并点击底部的”custom_export”。(如果使用文本编辑器,定义下面的函数。)参数 players
是数据库中所有玩家组成的queryset。使用 yield
来获取每一行数据。
def custom_export(players):
# header row
yield ['session', 'participant_code', 'round_number', 'id_in_group', 'payoff']
for p in players:
participant = p.participant
session = p.session
yield [session.code, participant.code, p.round_number, p.id_in_group, p.payoff]
Or, you can ignore the players
argument and export some other data instead, e.g.:
def custom_export(players):
# Export an ExtraModel called "Trial"
yield ['session', 'participant', 'round_number', 'response', 'response_msec']
# 'filter' without any args returns everything
trials = Trial.filter()
for trial in trials:
player = trial.player
participant = player.participant
session = player.session
yield [session.code, participant.code, player.round_number, trial.response, trial.response_msec]
一旦定义了这个函数,你的自定义数据导出就在正常的数据导出页面可用。
Debug信息¶
当oTree以 DEBUG
模式(也即环境变量 OTREE_PRODUCTION
未被设置)运行时,debug信息会被显示在屏幕的底部。
报酬¶
如果你定义了一个名为 finished 的 participant field,那么你可以在参与人完成会话之后设置 participant.finished = True
,这会在报酬页面等不同地方显示。
房间¶
oTree允许你配置”房间”,它提供了下面的特性:
- 在session之间保持不变的链接,你可以将其分发给参与人或者实验室电脑
- “等待房间”允许你查看哪些参与者目前正在等待session开始。
- 易于参与人输入的短链接,利于快速现场演示。
下面是屏幕截图:

创建房间¶
你可以创建多个房间——比如说,对应你教学的不同班级,你管理的不同实验室。
如果使用oTree Studio¶
在侧边栏,前往”Settings”并在底部添加一个房间。
如果使用PyCharm¶
前往 settings.py
并设置 ROOMS
。
例如:
ROOMS = [
dict(
name='econ101',
display_name='Econ 101 class',
participant_label_file='_rooms/econ101.txt',
use_secure_urls=True
),
dict(
name='econ_lab',
display_name='Experimental Economics Lab'
),
]
如果你使用了参与人标签(参见下文),你需要一个 participant_label_file
,它是一个相对(或绝对)路径,指向一个保存着参与人标签的文本文件。
配置房间¶
参与人标签¶
这是房间的”访客列表”。它应当每一行包含一个参与人标签。例如:
LAB1
LAB2
LAB3
LAB4
LAB5
LAB6
LAB7
LAB8
LAB9
LAB10
如果你没有指定参与人标签,那么任何人都可以进入房间,只要他知道了房间的URL。参见 如果不使用participant_label_file。
use_secure_urls (可选的)¶
这一设置提供了 participant_label_file
之上额外的安全性。举例来说,不使用安全URL时,你的开始链接看起来就像下面这样:
http://localhost:8000/room/econ101/?participant_label=Student1
http://localhost:8000/room/econ101/?participant_label=Student2
如果Student1是恶意的,他可能会将他URL中的参与人标签从”Student1”改为”Student2”,这样它就可以冒充Student2。然而,如果你使用了 use_secure_urls
,每个URL会获得一个唯一的代码,像下面这样:
http://localhost:8000/room/econ101/?participant_label=Student1&hash=29cd655f
http://localhost:8000/room/econ101/?participant_label=Student2&hash=46d9f31d
这样,Student1由于不知道这一代码就无法冒充Student2。
使用房间¶
在admin界面,单击导航栏的”Rooms”,然后点击你创建的房间。向下滑动到参与人URL的那一部分。
如果使用participant_label_file¶
在房间的admin页面,观察哪些参与人已经到达,当你准备完毕后,创建一个你指定人数的会话。
你可以使用指定参与人的URL,也可以使用房间的URL。
指定参与人的URL已经包含参与人标签。例如:
http://localhost:8000/room/econ101/?participant_label=Student2
房间的URL并不包含参与人标签:
http://localhost:8000/room/econ101/
所以如果你使用了房间的URL,参与人会被要求输入他们的参与人标签:

如果不使用participant_label_file¶
直接让每位参与人打开房间URL。然后,在房间的admin页面,查看多少人已经到场,然后创建你指定人数的会话。
尽管这一方案很简单,但是它相对使用参与人标签的方案有些不可靠。因为有些人可能会通过在两个不同的浏览器里打开链接的方式玩两次。
为多个会话复用¶
房间的URL被设计为可在不同会话间复用。在实验室中,你可以将其设置为浏览器的主页(使用房间的URL或指定参与人的URL)。
在教学实验中,你可以给每一位学生他们可以使用整个学期的URL。
如果参与人没有都到场怎么办?¶
如果你在进行一项实验室实验并且参与者的人数是难以预测的,你可以考虑使用房间URL,并要求参与者手动输入他们的参与人标签。参与者仅在输入他们的参与者标签之后才被算为到场。
或者,你可以打开浏览器并输入特定参与人的URL,在会话开始之前关闭那些未参加者的电脑。
参与人可以在会话开始之后加入,只要还有位置。
预分配标签到参与者¶
oTree按照到达时间分配参与者顺序,例如第一个到达的人就是参与者1。然而,这在某些情况下并不是我们所期望的,举例来说:
- 你希望参与者标签按照oTree ID排成固定的顺序,例如LAB29总是参与者29。
- 你希望Alice/Bob/Charlie总是参与者1/2/3,这样他们就会在同一组中进行游戏。
在 creating_session
中分配参与者标签即可:
def creating_session(subsession):
labels = ['alice', 'bob', 'charlie']
for player, label in zip(subsession.get_players(), labels):
player.participant.label = label
如果有人打开了含有 participant_label=alice
的开始链接,oTree会检查是否会话中已经有参与人拥有那个标签。(这是必须的为了点击开始链接两次会分配标签给同一个参与者。)
向oTree传递参与人的数据¶
货币¶
在许多实验中,参与者为了某种货币进行游戏:可能是真实的钱,也可能是点数。oTree两种都支持;你可以通过在 settings.py
中设置 USE_POINTS = False
将点数改为真实的钱。
你可以使用 cu(42)
来表示“42单位货币”。它就像一个数字一样(例如 cu(0.1) + cu(0.2) == cu(0.3)
)。好处是当展示给用户时,它会自动格式化为 $0.30
或 0,30 €
,等等。这取决于你的 REAL_WORLD_CURRENCY_CODE
与 LANGUAGE_CODE
设置。
注解
cu()
在oTree 5中新引入。之前使用 c()
来表示货币。使用 c()
的代码会继续正常工作。更多信息在 这里。
使用 CurrencyField
在数据库中存储货币。例如:
class Player(BasePlayer):
random_bonus = models.CurrencyField()
要创建货币值的列表,使用 currency_range
:
currency_range(0, 0.10, 0.02)
# this gives:
# [$0.00, $0.02, $0.04, $0.06, $0.08, $0.10]
在模板中,你应当使用 |cu
过滤器而不是 cu()
函数。举例来说, {{ 20|cu }}
会显示为 20 points
。
收益¶
每位玩家都有一个 payoff
字段。如果你的玩家获得了钱,你应当将其存在此字段中。 participant.payoff
自动存储了所有子会话的收益之和。你可以直接修改 participant.payoff
,例如将最终收益四舍五入变为整数。
在实验结束时,参与人的总收益可以通过 participant.payoff_plus_participation_fee()
获取;这是通过将 participant.payoff
转换为真实世界的货币(如果 USE_POINTS
为 True
),再加上 session.config['participation_fee']
计算得来的。
点数(即“实验货币”)¶
你可以设置 USE_POINTS = True
,那么货币值就会使用点数而不是美元/欧元/等等。举例来说, cu(10)
会被显示为 10 points
(或者 10 puntos
,诸如此类)
你可以通过在你的session config中增加一个 real_world_currency_per_point
条目来决定与实际货币的兑换比例。
将点数转换为现实世界货币¶
你可以通过 .to_real_world_currency()
方法来将点数转换为钱。例如:
cu(10).to_real_world_currency(session)
( session
是必须的,因为不同的会话会有不同的转换率)。
小数位数¶
钱数使用两位小数展示。
从另一方面说,点数都是整数。这意味着数值会被四舍五入为整数,如 10
除以 3
是 3
。所以,我们推荐使用足够大数量级的点数,这样就不用担心舍入的错误。举例来说,将某个游戏的捐赠设为1000点,而不是100。
MTurk & Prolific¶
MTurk¶
总览¶
oTree提供与Amazon Mechanical Turk (MTurk)的集成:
- 从oTree的管理员界面,你可以将你的会话发布至MTurk。
- Mechanical Turk上的worker可以参与你的会话。
- 从oTree的管理界面,你可以向参与者发放他们的报酬和额外奖励(payoff)。
安装¶
MTurk模板¶
将下面的内容放入你的 mturk_template.html
:
<script src="https://assets.crowd.aws/crowd-html-elements.js"></script>
<crowd-form>
<div style="padding: 20px">
<p>
This HIT is an academic experiment on decision making from XYZ University....
After completing this HIT, you will receive your reward plus a bonus payment....
</p>
<p>After
you have accepted this HIT, the URL to the study will appear here: <b><a class="otree-link">link</a></b>.
</p>
<p>
On the last page, you will be given a completion code.
Please copy/paste that code below.
</p>
<crowd-input name="completion_code" label="Enter your completion code here" required></crowd-input>
<br>
</div>
</crowd-form>
你可以轻易地测试页面的外观,通过将内容放入一个.html文件,并双击在浏览器中打开。你可以根据你的需要修改 <crowd-form>
内部的内容,但注意下面的事项:
- 实验的链接应当如
<a class="otree-link">Link text</a>
。一旦用户接受了任务,oTree会自动为这些链接添加href
使它们指向你的实验。 - 如果你想要完成代码显示在oTree管理员页面(Payments选项卡),你需要一个名为
completion_code
的<crowd-input>
。
让你的会话在MTurk上运行¶
在你的实验的最后一个页面,给用户一个完成代码。例如,你可以简单地显示:“你已经完成了实验。你的完成代码是TRUST2020。”如果你愿意,你可以生成唯一的完成代码。你无需过于担心完成代码,因为oTree追踪了每位worker的MTurk ID并将其显示在管理员页面上,同时显示他们是否到达了最终页面。完成代码只是一层额外的验证,它给与了worker一个特定的他们习惯于有的目标。
本地沙箱测试¶
在开始实验之前,你必须创建一个MTurk雇主账户,获取你的 AWS_ACCESS_KEY_ID
和 AWS_SECRET_ACCESS_KEY
。
You can obtain these credentials in your AWS Management Console.
为了在进行本地MTurk沙箱测试,查看实验展示给worker的样子,你需要将这些证书存储进你的电脑。
如果使用Windows,在控制面版里搜索“环境变量”,并像下面这样创建两个环境变量:

在Mac上,将你的证书像这样放入你的 ~/.bash_profile
文件:
export AWS_ACCESS_KEY_ID=AKIASOMETHINGSOMETHING
export AWS_SECRET_ACCESS_KEY=yoursecretaccesskeyhere
重启你的命令行窗口并运行oTree。在oTree管理员界面,点击“Session”,在按钮 “Create New Session”下,选择”For MTurk”:

在你的web服务器上设置环境变量¶
如果你使用Heroku,前往你的App Dashboard的”settings”界面,并设置config vars AWS_ACCESS_KEY_ID
和 AWS_SECRET_ACCESS_KEY
。
资格要求¶
oTree使用boto3语法进行资格要求。下面是两个资格要求的例子,你可以将其复制到你的 qualification_requirements
设置中:
[
{
'QualificationTypeId': "3AWO4KN9YO3JRSN25G0KTXS4AQW9I6",
'Comparator': "DoesNotExist",
},
{
'QualificationTypeId': "4AMO4KN9YO3JRSN25G0KTXS4AQW9I7",
'Comparator': "DoesNotExist",
},
]
下面展示了如何要求worker来自美国。 (00000000000000000071 是基于位置的资格类型代码。)
[
{
'QualificationTypeId': "00000000000000000071",
'Comparator': "EqualTo",
'LocaleValues': [{'Country': "US"}]
},
]
参见 MTurk API reference。(然而,注意到这里的代码示例使用的是JavaScript,所以你需要修改语法才能使之运行在Python下,例如为字典的键添加引号。)
注意:当运行在沙箱模式下时,oTree忽略资格要求。
避免重复参加(重复的worker)¶
为了避免一个worker参加两次,你可以在实验中对每一个worker设置一个验证(Qualification),并屏蔽已有此验证的人。
登录到你的MTurk账号并创建一个验证。前往你的oTree MTurk设置并粘贴验证ID到 grant_qualification_id
。然后,添加一个条目到 qualification_requirements
:
{
'QualificationTypeId': "YOUR_QUALIFICATION_ID_HERE",
'Comparator': "DoesNotExist",
},
多人游戏与中途退出者¶
涉及到等待页面的游戏在Mechanical Turk上实现困难,因为有些参与者会中途退出或直到接受任务之后一段时间才开始游戏。
为了减少这种事情的发生,参考 避免玩家卡在等待页面 中的推荐做法。
当你为MTurk创建了一个N个参与人的会话,oTree实际上会创建(N*2)个参与者,因为在某些MTurk worker开始但又退回了任务时需要这种冗余。
管理HIT¶
oTree provides the ability to approve/reject assignments, send bonuses, and expire HITs early.
If you want to do anything beyond this, (e.g. extend expiration date, interact with workers, send custom bonuses, etc), you will need to install the MTurk command-line tools.
杂项¶
如果你使用其他服务如TurkPrime发布到MTurk,你可能无需按照本页面的步骤操作。
杂项¶
REST¶
oTree有一个REST API使得外部程序(如其他网站)可与oTree通信。
REST API是一个在你的服务器上被设计用来被其他程序使用的URL,而不是用于在浏览器中手动打开的。
一个使用了很多REST API的项目是 oTree HR。
开始¶
注解
“这些代码应当放在哪里”
这些代码无需包含在你的oTree项目文件夹中。由于REST API的关键就在于使得外部程序和服务器可以与oTree通过网络通信,你应当将这些代码放在其他程序中。这意味着你可以使用在其他服务器上适用的任何语言来编写程序。本页面上的例子使用的是Python,但是使用其他编程语言进行HTTP请求也很简单,如webhooks工具或者cURL。
import requests # pip3 install requests
from pprint import pprint
GET = requests.get
POST = requests.post
# if using Heroku, change this to https://YOURAPP.herokuapp.com
SERVER_URL = 'http://localhost:8000'
REST_KEY = '' # fill this later
def call_api(method, *path_parts, **params) -> dict:
path_parts = '/'.join(path_parts)
url = f'{SERVER_URL}/api/{path_parts}/'
resp = method(url, json=params, headers={'otree-rest-key': REST_KEY})
if not resp.ok:
msg = (
f'Request to "{url}" failed '
f'with status code {resp.status_code}: {resp.text}'
)
raise Exception(msg)
return resp.json()
“oTree version” endpoint¶
注解
在2021年3月新引入的beta特性。
GET URL: /api/otree_version/
例子¶
data = call_api(GET, 'otree_version')
# returns: {'version': '5.0.0'}
“Session configs” endpoint¶
注解
在2021年3月新引入的beta特性。
GET URL: /api/session_configs/
返回所有session config组成的列表,使用字典存储它们的所有属性。
例子¶
data = call_api(GET, 'session_configs')
pprint(data)
“Rooms” endpoint¶
注解
在2021年3月新引入的beta特性。
GET URL: /api/rooms/
例子¶
data = call_api(GET, 'rooms')
pprint(data)
示例输出(注意它包含了 session_code
,如果房间中当前存在一个会话的话):
[{'name': 'my_room',
'session_code': 'lq3cxfn2',
'url': 'http://localhost:8000/room/my_room'},
{'name': 'live_demo',
'session_code': None,
'url': 'http://localhost:8000/room/live_demo'}]
“Create sessions” endpoint¶
POST URL: /api/sessions/
下面是一些”create sessions”路径应当如何使用的例子:
- 其他网站可自动创建oTree会话
- 你可以自己创造一个更好看的oTree 配置会话 界面(例如加上侧边栏和控件)
- 在某些固定时刻创建oTree会话的进程
- 创建自定义会话的命令行脚本(如果
otree create_session
不够用的话)
例子¶
data = call_api(
POST,
'sessions',
session_config_name='trust',
room_name='econ101',
num_participants=4,
modified_session_config_fields=dict(num_apples=10, abc=[1, 2, 3]),
)
pprint(data)
“Get session data” endpoint¶
注解
2021年3月引入的新特性。将处于测试阶段直到获取足够的用户反馈。
GET URL: /api/sessions/{code}
此API将取回关于会话及其参与人的数据。如果略去 participant_labels
,那么将返回所有参与人的数据。
例子¶
data = call_api(GET, 'sessions', 'vfyqlw1q', participant_labels=['Alice'])
pprint(data)
“Session vars” endpoint¶
注解
在2021年4月,这一路径要求你传入会话代码作为路径参数。如果会话在房间中的话,你可以通过 rooms
路径获得会话代码。
POST URL: /api/session_vars/{session_code}
这一路径使你可以设置 session.vars
。一种用途是实验输入。举例来说,如果实验中有一次抽奖,就可以通过运行下面的脚本来输入结果。
例子¶
call_api(POST, 'session_vars', 'vfyqlw1q', vars=dict(dice_roll=4))
“Participant vars” endpoint¶
POST URL: /api/participant_vars/{participant_code}
通过 web services / webhooks 将参与人的信息传递给oTree。
例子¶
call_api(POST, 'participant_vars', 'vfyqlw1q', vars=dict(birth_year='1995', gender='F'))
“Participant vars for room” endpoint¶
POST URL: /api/participant_vars/
类似于”participant vars”路径,但这一API可在你不知道参与人代码时使用。你可以通过房间的名字以及参与人标签来指定参与人。
例子¶
call_api(
POST,
'participant_vars',
room_name='qualtrics_study',
participant_label='albert_e',
vars=dict(age=25, is_male=True, x=[3, 6, 9]),
)
参数¶
room_name
(必要)participant_label
(必要)vars
(必要): 用于加入participant vars的字典, 其中值必须是可被JSON序列化的数据类型, 甚至可以是嵌套的字典/列表。
你需要给参与人一个带 participant_label
的链接,尽管它不必包含在 participant_label_file
中。
认证¶
如果你将你的验证等级从DEMO改为STUDY,你必须验证你的REST API请求。
在服务器上创建一个环境变量(即 Heroku config var) OTREE_REST_KEY
。将其设置为某个秘密值。
在发送请求时,在HTTP header里将其添加为 otree-rest-key
。如果按照上面的 例子 ,你应当设置 REST_KEY
变量。
演示 & 测试¶
为在开发时更便利,你可以在一个真实的会话中生成假的vars来模拟从REST API获得的数据。
在你的session config中,添加参数 mock_exogenous_data=True
(称其为 exogenous(外源性) 数据是因为其是在oTree外部生成的。)
然后在你的项目的shared_out.py中(如果你使用的是文本编辑器,你可能需要创建此文件)定义一个同名函数(mock_exogenous_data
)。
下面是一个例子:
def mock_exogenous_data(session):
participants = session.get_participants()
for pp in participants:
pp.vars.update(age=20, is_male=True) # or make it random
你也可以在此处设置参与人标签。
当你的会话运行在demo模式下,或者使用bot, mock_exogenous_data()
会在 creating_session
之后自动运行。然而,如果是在房间里创建的会话则不会运行。
如果你有多个session config需要不同的外源性数据,你可以像下面这样区分它们:
def mock_exogenous_data(session):
if session.config['name'] == 'whatever':
...
if 'xyz' in session.config['app_sequence']:
...
Localization¶
Changing the language setting¶
Go to your settings and change LANGUAGE_CODE
:.
For example:
LANGUAGE_CODE = 'fr' # French
LANGUAGE_CODE = 'zh-hans' # Chinese (simplified)
This will customize certain things such validation messages and formatting of numbers.
提示与技巧¶
避免代码重复¶
尽可能避免复制粘贴同一段代码到多个地方是很好的习惯。尽管有时需要一些思考才能明白如何避免复制粘贴代码,但是将代码只重复一次通常会在你需要更改设计或者修复错误时为你节省很多时间和精力。
下面是一些实现代码复用的技巧。
不要将你的应用复制多份¶
你应当尽可能避免复制你的应用文件夹来创建一个有细微差别的版本,因为此时重复代码很难维护。
如果你需要多轮游戏,可通过设置 NUM_ROUNDS
来实现。如果你需要略微不同的版本(例如不同的实验组),那么你应当使用 实验组 中所描述的技巧,比如创建两个session config在其中设置不同的 'treatment'
参数,并在你的代码中检查 session.config['treatment']
来进行区分。
如何创建多个字段¶
假定你的应用中有很多几乎完全一样的字段,例如:
class Player(BasePlayer):
f1 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f2 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f3 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f4 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f5 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f6 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f7 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f8 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f9 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f10 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
# etc...
这太复杂了,你应当找到一个能将其简化的方法。
这些字段都显示在单独的页面上吗?如果是的,考虑将其转换为一个仅有一个字段的10轮游戏。
如果上述方法不可行,你可以通过定义一个返回字段的函数来减少重复代码的数量:
def make_field(label):
return models.IntegerField(
choices=[1,2,3,4,5],
label=label,
widget=widgets.RadioSelect,
)
class Player(BasePlayer):
q1 = make_field('I am quick to understand things.')
q2 = make_field('I use difficult words.')
q3 = make_field('I am full of ideas.')
q4 = make_field('I have excellent ideas.')
通过使用多轮游戏避免代码重复¶
如果你有很多几乎完全一样的页面,考虑仅保留一个页面并将游戏重复多轮。你的代码可被简化的一个标志是它看上去像下面这样:
# [pages 1 through 7....]
class Decision8(Page):
form_model = 'player'
form_fields = ['decision8']
class Decision9(Page):
form_model = 'player'
form_fields = ['decision9']
# etc...
避免重复的验证方法¶
如果你有多个重复的 FIELD_error_message 方法,你可以使用一个 error_message 函数来替换它们。例如:
def quiz1_error_message(player, value):
if value != 42:
return 'Wrong answer'
def quiz2_error_message(player, value):
if value != 'Ottawa':
return 'Wrong answer'
def quiz3_error_message(player, value):
if value != 3.14:
return 'Wrong answer'
def quiz4_error_message(player, value):
if value != 'George Washington':
return 'Wrong answer'
你可以在你的页面中定义一个函数来替换:
@staticmethod
def error_message(player, values):
solutions = dict(
quiz1=42,
quiz2='Ottawa',
quiz3='3.14',
quiz4='George Washington'
)
error_messages = dict()
for field_name in solutions:
if values[field_name] != solutions[field_name]:
error_messages[field_name] = 'Wrong answer'
return error_messages
(通常 error_message
被用来返回某一错误信息字符串,但你也可以返回一个字典。)
避免重复的页面函数¶
任何页面函数均可被移除页面类而变为顶层函数。这是一种在多个页面之间共享函数的便利的方法。例如,假定许多页面都需要这2个函数:
class Page1(Page):
@staticmethod
def is_displayed(player: Player):
participant = player.participant
return participant.expiry - time.time() > 0
@staticmethod
def get_timeout_seconds(player):
participant = player.participant
import time
return participant.expiry - time.time()
你可以将这些函数移至所有页面之前(移除 @staticmethod
),并在需要使用时引用它们:
def is_displayed1(player: Player):
participant = player.participant
return participant.expiry - time.time() > 0
def get_timeout_seconds1(player: Player):
participant = player.participant
import time
return participant.expiry - time.time()
class Page1(Page):
is_displayed = is_displayed1
get_timeout_seconds = get_timeout_seconds1
class Page2(Page):
is_displayed = is_displayed1
get_timeout_seconds = get_timeout_seconds1
(在样例游戏中, after_all_players_arrive
与 live_method
经常以这种方式被定义。)
提高代码性能¶
你应当避免不必要的 get_players()
, get_player_by_id()
, in_*_rounds()
, get_others_in_group()
或者任何返回一个玩家或者玩家列表的函数的调用。这些方法都需要数据库查询,而数据库查询是很慢的。
例如,下面的代码有冗余的查询,对同一玩家进行了5次数据库查询:
@staticmethod
def vars_for_template(player):
return dict(
a=player.in_round(1).a,
b=player.in_round(1).b,
c=player.in_round(1).c,
d=player.in_round(1).d,
e=player.in_round(1).e
)
这应当被简化为:
@staticmethod
def vars_for_template(player):
round_1_player = player.in_round(1)
return dict(
a=round_1_player.a,
b=round_1_player.b,
c=round_1_player.c,
d=round_1_player.d,
e=round_1_player.e
)
作为额外的好处,这通常使得代码更具有可读性。
使用BooleanField而不是StringField,如果可能的话¶
许多 StringFields
应当被转化为 BooleanFields
,尤其是其仅有2个不同的值时。
假定你有一个字段叫做 treatment
:
treatment = models.StringField()
假定 treatment
有4个不同的可能值:
high_income_high_tax
high_income_low_tax
low_income_high_tax
low_income_low_tax
在你的页面中,你可能会这样使用它:
class HighIncome(Page):
@staticmethod
def is_displayed(player):
return player.treatment == 'high_income_high_tax' or player.treatment == 'high_income_low_tax'
class HighTax(Page):
@staticmethod
def is_displayed(player):
return player.treatment == 'high_income_high_tax' or player.treatment == 'low_income_high_tax'
将其化为两个单独的BooleanFields是很好的改进:
high_income = models.BooleanField()
high_tax = models.BooleanField()
那么你的页面代码可被简化为:
class HighIncome(Page):
@staticmethod
def is_displayed(player):
return player.high_income
class HighTax(Page):
@staticmethod
def is_displayed(player):
return player.high_tax
field_maybe_none¶
If you access a Player/Group/Subsession field whose value is None
, oTree will raise a TypeError
.
This is designed to catch situations where a user forgot to assign a value to that field,
or forgot to include it in form_fields
.
However, sometimes you need to intentionally access a field whose value may be None
.
To do this, use field_maybe_none
, which will suppress the error:
# instead of player.abc, do:
abc = player.field_maybe_none('abc')
# also works on group and subsession
注解
field_maybe_none
is new in oTree 5.4 (August 2021).
An alternative solution is to assign an initial value to the field so that its value is never None
:
abc = models.BooleanField(initial=False)
xyz = models.StringField(initial='')
高级特性¶
这些高级特性大多数在oTree Studio中不被支持。
附加模型¶
当单个玩家就需要存储几十上百个数据点时,附加模型非常有用。例如存在一系列出价,刺激与反馈时。这常与 实时页面 一起使用。
这里 有很多例子。
一个附加模型应当链接到另一个模型上:
class Bid(ExtraModel):
player = models.Link(Player)
amount = models.CurrencyField()
每当用户出价时,将其存到数据库中:
Bid.create(player=player, amount=500)
之后,你就可以提取出玩家出价的列表:
bids = Bid.filter(player=player)
一个附加模型可以有多个链接:
class Offer(ExtraModel):
sender = models.Link(Player)
receiver = models.Link(Player)
group = models.Link(Group)
amount = models.CurrencyField()
accepted = models.BooleanField()
此时你就可以以多种方式查询:
this_group_offers = Offer.filter(group=group)
offers_i_accepted = Offer.filter(receiver=player, accepted=True)
对于更加复杂的过滤与排序,你应当使用列表操作:
offers_over_500 = [o for o in Offer.filter(group=group) if o.amount > 500]
参考心理学实验的例子,如斯特鲁普测试,它们展示了如何从CSV表格中的每一行中生成附加模型的数据。
To export your ExtraModel data to CSV/Excel, use 自定义数据导出.
Reading CSV files¶
注解
This feature is in beta (new in oTree 5.8.2)
To read a CSV file (which can be produced by Excel or any other spreadsheet app),
you can use read_csv()
. For example, if you have a CSV file like this:
name,price,is_organic
Apple,0.99,TRUE
Mango,3.79,FALSE
read_csv()
will output a list of dicts, like:
[dict(name='Apple', price=0.99, is_organic=True),
dict(name='Mango', price=3.79, is_organic=False)]
You call the function like this:
rows = read_csv('my_app/my_data.csv', Product)
The second argument is a class that specifies the datatype of each column:
class Product(ExtraModel):
name = models.StringField()
price = models.FloatField()
is_organic = models.BooleanField()
(Without this info, it would be ambiguous whether TRUE
is supposed to be a bool,
or the string 'TRUE'
, etc.)
read_csv()
does not actually create any instances of that class.
If you want that, you must use .create()
additionally:
rows = read_csv('my_app/my_data.csv', Product)
for row in rows:
Product.create(
name=row['name'],
price=row['price'],
is_organic=row['is_organic'],
# any other args:
player=player,
)
The model can be an ExtraModel
, Player
, Group
, or Subsession
.
It’s fine if it also contains other fields; they will be ignored by read_csv()
.
模板¶
template_name¶
如果模板需要有一个与你的页面类不同的名字(例如你在不同的页面中共享同一模板),设置 template_name
即可。例如:
class Page1(Page):
template_name = 'app_name/MyPage.html'
CSS/JS 与基模板¶
为将相同的JS/CSS包含在一个应用的所有页面中,可将其放入一个 static file 中,也可将其放入一个可包含的模板。
静态文件¶
下面说明了如何在页面中包含图片(或任何静态文件如.css, .js,等等)。
在你的oTree项目的根文件夹中,有一个 _static/
文件夹。将文件放在这里,例如 puppy.jpg
.然后,在你的模板中,你可以通过 {{ static 'puppy.jpg' }}
获取文件的URL。
要显示图片,使用 <img>
标签,如:
<img src="{{ static 'puppy.jpg' }}"/>
上面我们将文件保存在 _static/puppy.jpg
,但是实际上更好的做法是创建一个子文件夹,起名为你应用的名字,并存为 _static/your_app_name/puppy.jpg
,这使得文件组织有条理,并避免了名字冲突。
此时你的HTML代码变为:
<img src="{{ static 'your_app_name/puppy.jpg }}"/>
(如果你愿意的话,你也可以将静态文件放入你的应用文件夹,在子文件夹 static/your_app_name
中。)
如果静态文件在你更改了之后仍不改变,这是因为你的浏览器缓存了这个文件。重载整个页面即可(通常快捷键是 Ctrl+F5)
如果你需要视频或者高解析度的图片,更好的做法是将其存储在线上别的地方并使用URL进行引用。因为大文件使得上传 .otreezip文件非常缓慢。
等待页面¶
自定义等待页面模板¶
You can make a custom wait page template.
For example, save this to your_app_name/MyWaitPage.html
:
{{ extends 'otree/WaitPage.html' }}
{{ block title }}{{ title_text }}{{ endblock }}
{{ block content }}
{{ body_text }}
<p>
My custom content here.
</p>
{{ endblock }}
然后在等待页面中使用这一模板:
class MyWaitPage(WaitPage):
template_name = 'your_app_name/MyWaitPage.html'
此时你可以如寻常一样使用 vars_for_template
。实际上, body_text
与 title_text
属性就是设置 vars_for_template
的简略方式;下面两段代码是等价的:
class MyWaitPage(WaitPage):
body_text = "foo"
class MyWaitPage(WaitPage):
@staticmethod
def vars_for_template(player):
return dict(body_text="foo")
如果你想全局应用你的自定义等待页面模板,将其保存在 _templates/global/WaitPage.html
.oTree将自动地在所有地方使用它替换内置的等待页面。
货币¶
为将”points”自定义为别的名字如”tokens” 或 “credits”, 设置 POINTS_CUSTOM_NAME
,例如 POINTS_CUSTOM_NAME = 'tokens'
.
你可以改变真实世界货币的小数位数,通过设置 REAL_WORLD_CURRENCY_DECIMAL_PLACES
。如果额外的小数位数显示出来但总是0,那么你应当重置数据库。
Bots: 高级特性¶
这些高级特性大多数在oTree Studio中不被支持。
命令行bots¶
一种可选的在浏览器中运行bots的方式是在命令行中运行。命令行bots运行更快且需要更少的安装步骤。
运行下面的代码:
otree test mysession
使用指定参与者人数进行测试(否则默认为 num_demo_participants
):
otree test mysession 6
在所有session config上运行测试:
otree test
导出数据¶
使用 --export
选项来将结果导出为CSV文件:
otree test mysession --export
为指定数据所保存的文件夹,可使用:
otree test mysession --export=myfolder
命令行浏览器bots¶
你可以在命令行启动bots,通过使用 otree browser_bots
。
确保已安装了Google Chrome,或在
settings.py
中设置了BROWSER_COMMAND
(更多信息见下面)设置
OTREE_REST_KEY
环境变量如 REST 中所述。运行你的服务器
关闭所有Chrome窗口。
运行下面的代码:
otree browser_bots mysession
这会启动数个Chrome标签页并运行bots。当完成后,标签页会自动关闭,并且你会在终端窗口中看到一份报告。
如果Chrome没有正常关闭窗口,确保你在运行指令之前已经关闭了所有Chrome窗口。
远程服务器(例如 Heroku)上的命令行bots¶
如果服务器不是运行在通常的主机/端口 http://localhost:8000
上,你需要传递 --server-url
参数。例如,如果在Heroku上,你需要这样做:
otree browser_bots mysession --server-url=https://YOUR-SITE.herokuapp.com
选择session config与人数¶
你可以指定参与人的人数:
otree browser_bots mysession 6
为测试所有的session config,运行:
otree browser_bots
浏览器bots:杂项¶
你可以使用非Chrome浏览器,需在 settings.py
中设置 BROWSER_COMMAND
。然后,oTree会自动启动浏览器,通过类似 subprocess.Popen(settings.BROWSER_COMMAND)
的操作。
测试用例¶
你可以在你的PlayerBot类中定义属性 cases
来列出不同的测试用例。例如,在公共品博弈中,你可能想要测试三种场景:
- 所有玩家贡献其初始值的一半
- 所有玩家均不做任何贡献
- 所有玩家贡献出他们全部的初始值(100点)
你可以分别将3个测试用例命名为”basic”,”min” 和 “max”,并将他们放在 cases
中。然后,oTree会自动执行bot3次,每次一个测试用例。每一次执行时, cases
中的不同值都会在bot中被赋值给 self.case
。
例如:
class PlayerBot(Bot):
cases = ['basic', 'min', 'max']
def play_round(self):
yield (pages.Introduction)
if self.case == 'basic':
assert self.player.payoff == None
if self.case == 'basic':
if self.player.id_in_group == 1:
for invalid_contribution in [-1, 101]:
yield SubmissionMustFail(pages.Contribute, {'contribution': invalid_contribution})
contribution = {
'min': 0,
'max': 100,
'basic': 50,
}[self.case]
yield (pages.Contribute, {"contribution": contribution})
yield (pages.Results)
if self.player.id_in_group == 1:
if self.case == 'min':
expected_payoff = 110
elif self.case == 'max':
expected_payoff = 190
else:
expected_payoff = 150
assert self.player.payoff == expected_payoff
注解
如果你使用测试用例,更推荐使用 命令行bots 因为浏览器bots仅会执行一个单独的用例。
cases
必须是列表,但可以包含任何数据类型,如字符串,整数,甚至字典。下面是一个信任博弈,它使用字典作为用例。
class PlayerBot(Bot):
cases = [
{'offer': 0, 'return': 0, 'p1_payoff': 10, 'p2_payoff': 0},
{'offer': 5, 'return': 10, 'p1_payoff': 15, 'p2_payoff': 5},
{'offer': 10, 'return': 30, 'p1_payoff': 30, 'p2_payoff': 0}
]
def play_round(self):
case = self.case
if self.player.id_in_group == 1:
yield (pages.Send, {"sent_amount": case['offer']})
else:
for invalid_return in [-1, case['offer'] * C.MULTIPLICATION_FACTOR + 1]:
yield SubmissionMustFail(pages.SendBack, {'sent_back_amount': invalid_return})
yield (pages.SendBack, {'sent_back_amount': case['return']})
yield (pages.Results)
if self.player.id_in_group == 1:
expected_payoff = case['p1_payoff']
else:
expected_payoff = case['p2_payoff']
assert self.player.payoff == expected_payoff
error_fields¶
当在多个字段的表单上使用 SubmissionMustFail
时,你可以使用 error_fields
获得额外的提示。
例如,假定我们提交了一个合法的 age
,但同时提交了非法的 weight
与 height
:
yield SubmissionMustFail(
pages.Survey,
dict(
age=20,
weight=-1,
height=-1,
)
)
Bot系统不会告诉我们具体 为什么 提交会失败,这是缺失的信息。是 weight
还是 height
还是两者一起出错? error_fields
可以解决这种模棱两可的情况:
yield SubmissionMustFail(
pages.Survey,
dict(
age=20,
weight=-1,
height=-1,
),
error_fields=['weight', 'height']
)
它会查对出 weight
和 height
包含错误,但是 age
没有。
如果 error_message 返回一个错误,那么 error_fields
将会是 ['__all__']
。
杂项¶
在bots中,赋值语句 player = self.player
(或 participant = self.participant
,等等)是有风险的,即使这种代码在别处是被鼓励的。
因为如果有一个 yield
语句在中间,数据可能没有更新:
player = self.player
expect(player.money_left, cu(10))
yield pages.Contribute, dict(contribution=cu(1))
# don't do this!
# "player" variable still has the data from BEFORE pages.Contribute was submitted.
expect(player.money_left, cu(9))
直接使用 self.player.money_left
是安全的,因为 self.player
会从数据库中获取最新的数据。
实时页面¶
为使用机器人测试实时方法,在 tests.py
中定义顶级函数 call_live_method
。(在oTree Studio中不可用。)此函数可模拟一系列对你的 live_method
的调用。参数 method
模拟你的玩家模型中的实时方法。例如, method(3, 'hello')
调用了玩家3上的实时方法,并且 data
设置为 'hello'
。例如:
def call_live_method(method, **kwargs):
method(1, {"offer": 50})
method(2, {"accepted": False})
method(1, {"offer": 60})
retval = method(2, {"accepted": True})
# you can do asserts on retval
kwargs
至少包含下列参数。
case
如 测试用例 中所述。page_class
: 当前页面类,例如pages.MyPage
。round_number
当小组中最快的bot到达一个有 live_method
的页面时 call_live_method
会被自动执行。(其他bot此时可能在前一个页面,除非你使用等待页面限制了这种情况。)
oTree Lite¶
oTree 5 is based on oTree Lite, a new implementation of oTree that runs as a self-contained framework, not dependent on Django.
oTree Lite’s codebase is simpler and more self-contained. This makes it easier for me to add new features, investigate bug reports, and keep oTree simple to use.
Django comes with many features “out of the box”, so being based on Django initially helped oTree add new features and iterate quickly. However, Django brings complexity and restrictions. In the long run, the “framework inside a framework” approach becomes more of a liability.
Other advantages of oTree Lite:
- Simpler error messages
- Fewer dependencies such as Twisted that cause installation problems for some people
- Compatible with more versions of Python
- No need for Redis or second dyno
- Better performance
For the curious people who want to delve into oTree’s internal source code, you will have an easier time navigating oTree Lite.
How can I ensure I stay on oTree 3.x?¶
To ensure that you don’t install oTree Lite, you can specify <5
when you upgrade:
pip3 install -U "otree<5"
For Heroku, use one of the following formats in your requirements.txt
(replace 3.3.7 with whatever 3.x version you want):
otree<5
# or:
otree>=3.3.7,<5
# or:
otree==3.3.7
Upgrading¶
注解
I have set up a live chat on Discord to assist people upgrading from previous versions of oTree to oTree Lite.
oTree Lite is generally compatible with previous oTree apps. However, you will probably see small things that changed, especially in how forms and templates are rendered. This is somewhat inevitable as oTree has undergone a “brain transplant”. Please send any feedback to chris@otree.org.
Here are the most important differences:
模板¶
The template system is basically compatible with Django templates, except that only the basic tags & filters have been implemented:
- Tags:
{{ if }}
,{{ for }}
,{{ block }}
- Filters:
{{ x|json }}
,{{ x|escape }}
,{{ x|c }}
,{{ x|default("something") }}
There is no floatformat
filter, but there are new rounding filters that replace it.
For example:
{{ pi|floatformat:0 }} -> {{ pi|to0 }}
{{ pi|floatformat:1 }} -> {{ pi|to1 }}
{{ pi|floatformat:2 }} -> {{ pi|to2 }}
The |safe
filter and mark_safe
are not needed anymore, because the new template system does not
autoescape content. However, if you want to escape content (e.g. displaying an untrusted string to a different
player), you should use the |escape
filter.
Method calls must be at the end of the expression, and not followed by more dots.
For example, if you have a Player method called other_player()
,
you can do:
Your partner is {{ player.other_player }}
But you cannot do:
Your partner's decision was {{ player.other_player.decision }}
Forms¶
In templates, if you are doing manual form rendering, you should change
{% form.my_field.errors %}
to {{ formfield_errors 'my_field' }}
.
Older oTree formats¶
oTree Lite does not implement support for certain features found in older oTree
projects. To check you should run otree update_my_code
,
which will tell you the changes you need to make before your code can run on oTree Lite.
(It will also fix a few things automatically.)
A few common issues:
- The
Slider
widget is unavailable. You should instead use 原始HTML控件 (which has been the recommended solution anyway)
Bootstrap¶
Since bootstrap 5 beta just got released, I included it in this package. Certain things are different from bootstrap 4; consult the bootstrap migration docs. In my experience the main things that differed are:
data-*
attributes are renamed todata-bs-*
form-group
no longer exists
Misc¶
- In
get_group_matrix
returns a matrix of integers, rather than a matrix of player objects. To preserve the previous behavior, you should passobjects=True
, like.get_group_matrix(objects=True)
. - Translating an app to multiple languages works differently. See Localization.
- If you try to access a Player/Group/Subsession field whose value is still
None
, oTree will raise an error. More details here: field_maybe_none.
Django¶
This new implementation does not use Django or Channels in any way. So, it will not run any code you got from Django documentation, such as Django views, ModelForms, ORM, etc.
Version history¶
Version 5.10¶
For IntegerField/FloatField/CurrencyField, if min
is not specified, it will be assumed to be 0.
If you need a form field to accept negative values, set min=
to a negative value (or None
).
Benefits of this change:
- Most numeric inputs on mobile can now use the numeric keypad
- Prevents unintended negative inputs from users.
For example, if you forgot to specify
min=0
for your “contribution” field, then a user could ‘hack’ the game by entering a negative contribution.
Other changes:
- MTurk integration works even on Python >= 3.10 (removed dependency on the boto3 library)
- Python 3.11 support
- bots: better error message when bot is on the wrong page
Version 5.9¶
- Improved dropout detection
- Renamed
formInputs
(JavaScript variable) toforminputs
- 5.9.5: fix bug that points inputs allow decimal numbers when they should be whole numbers.
Version 5.8¶
- Better dropout detection with group_by_arrival_time; see here.
- Python 3.10 support
- Fix various websocket-related errors such as ConnectionClosedOK, IncompleteReadError, ClientDisconnect that tend to happen intermittently, especially with browser bots.
Version 5.6¶
- Added access to form inputs through JavaScript.
Version 5.4¶
- PARTICIPANT_FIELDS are now included in data export
- field_maybe_none
- Radio buttons can now be accessed by numeric index, e.g.
{{ form.my_field.0 }}
. - Bugfix with numpy data types assigned to model fields
- Misc improvements and fixes
Version 5.3¶
- Bugfix to deleting sessions in devserver
{{ static }}
tag checks that the file exists- In SessionData tab, fix the “next round”/”previous round” icons on Mac
- Fix to currency formatting in Japanese/Korean/Turkish currency (numbers were displayed with a decimal when there should be none)
- allow error_message to be run on non-form pages (e.g. live pages)
- Better error reporting when an invalid value is passed to
js_vars
- Minor fixes & improvements
Version 5.2¶
- For compatibility with oTree 3.x,
formfield
<input>
elements now prefix theirid
attribute withid_
. If you usegetElementById
/querySelector
/etc. to select any formfield inputs, you might need to update your selectors. - The data export now outputs “time started” as UTC.
- “Time spent” data export has a column name change.
If you have been using the
pagetimes.py
script, you should download the new version.
Version 5.1¶
- Breaking changes to REST API
Version 5.0¶
- oTree Lite
- The no-self format
- The beta method
Player.start()
has been removed. cu()
is now available as an alias forCurrency
.c()
will still work as long as you havefrom otree.api import Currency as c
at the top of your file. More details here.- oTree 3.x used two types of tags in templates:
{{ }}
and{% %}
. Starting in oTree 5, however, you can forget about{% %}
and just use{{ }}
everywhere if you want. More details here. - All REST API calls now return JSON
Version 3.3¶
- BooleanField now uses radio buttons by default (instead of dropdown)
otree zip
can now keep your requirements.txt up to date.- oTree no longer installs sentry-sdk. If you need Sentry on Heroku, you should add it to your requirements.txt manually.
- Faster server
- Faster startup time
- Faster installation
- Data export page no longer outputs XLSX files. Instead it outputs CSV files formatted for Excel
- Admin UI improvements, especially session data tab
Version 3.2¶
- Should use less memory and have fewer memory spikes.
- Enhancements to SessionData and SessionMonitor.
Version 3.1¶
- New way to define 角色
- You can pass a string to
formfield
, for example{{ formfield 'contribution' }}
.
Version 3.0¶
Other things¶
- Python 3.8 is now supported.
- Speed improvements to devserver & zipserver
- You can now download a single session’s data as Excel or CSV (through session’s Data tab)
- When browser bots complete, they keep the last page open
- group_by_arrival_time: quicker detection if a participant goes offline
- Browser bots use the REST API to create sessions (see REST).
- Instead of
runprodserver
you can now useprodserver
(that will be the preferred name going forward). - “Page time” data export now has more details such as whether it is a wait page.
devserver
andzipserver
now must usedb.sqlite3
as the database.
Version 2.5¶
- Removed old
runserver
command. - Deprecated non-oTree widgets and model fields. See here.
Version 2.4¶
zipserver
command- New MTurk format
- oTree no longer records participants’ IP addresses.
Version 2.3¶
- Various improvements to performance, stability, and ease of use.
- oTree now requires Python 3.7
- oTree now uses Django 2.2.
- Chinese/Japanese/Korean currencies are displayed as 元/円/원 instead of ¥/₩.
- On Windows,
prodserver
just launches 1 worker process. If you want more processes, you should use a process manager. (This is due to a limitation of the ASGI server) prodserver
uses Uvicorn/Hypercorn instead of Daphne- update_my_code has been removed
Version 2.2¶
- support for the
otreezip
format (otree zip
,otree unzip
) - MTurk: in sandbox mode, don’t grant qualifications or check qualification requirements
- MTurk: before paying participants, check if there is adequate account balance.
- “next button” is disabled after clicking, to prevent congesting the server with duplicate page loads.
- Upgrade to the latest version of Sentry
- Form validation methods should go on the model, not the page. See 动态表单验证
- app_after_this_page
- Various performance and stability improvements
Version 2.1¶
- oTree now raises an error if you use an undefined variable in your template.
This will help catch typos like
{{ Player.payoff }}
or{{ if player.id_in_gruop }}
. This means that apps that previously worked may now get a template error (previously, it failed silently). If you can’t remove the offending variable, you can apply the|default
filter, like:{{ my_undefined_variable|default:None }}
- oTree now warns you if you use an invalid attribute on a Page/WaitPage.
- CSV/Excel data export is done asynchronously, which will fix timeout issues for large files on Heroku.
- Better performance, especially for “Monitor” and “Data” tab in admin interface
The new no-self format¶
Since 2021, there has been a new optional format for oTree apps.
It replaces models.py
and pages.py
with a single __init__.py
.
The new format unifies oTree’s syntax.
For example, before, you needed to write either player.payoff
, self.payoff
,
or self.player.payoff
, depending on what part of the code you were in.
Now, you can always write player.payoff
.
In fact, the self
keyword has been eliminated entirely
If you use oTree Studio, your code has already been upgraded to the no-self format.
If you use a text editor, you can keep using the existing format, or use the new one if you wish. They both have access to the same features. The models.py format will continue to be fully supported and get access to the newest features.
注解
In January 2022, constants changed format also. See 2022 Constants format change
About the new format¶
- “self” is totally gone from your app’s code.
- Whenever you want to refer to the player, you write player. Same for group and subsession.
- Each method in oTree is changed to a function.
- There is no more models.py and pages.py. The whole game fits into one file (__init__.py).
- Everything else stays the same. All functions and features do the same thing as before.
Here is an example of an __init__.py in the “no self” format (with the dictator game):
class Subsession(BaseSubsession):
pass
class Group(BaseGroup):
pass
class Player(BasePlayer):
kept = models.CurrencyField(
min=0,
max=C.ENDOWMENT,
label="I will keep",
)
# FUNCTIONS
def set_payoffs(group):
player1 = group.get_player_by_id(1)
player2 = group.get_player_by_id(2)
player1.payoff = group.kept
player2.payoff = C.ENDOWMENT - group.kept
# PAGES
class Introduction(Page):
pass
class Offer(Page):
form_model = 'group'
form_fields = ['kept']
def is_displayed(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):
group = player.group
return dict(payoff=player.payoff, offer=C.ENDOWMENT - group.kept)
So, what has changed?
- As you see, set_payoffs has changed from a group method to a regular function that takes “group” as its argument. This should be clearer to most people.
- is_displayed and vars_for_template are no longer page methods that take an argument ‘self’, but direct functions of the player. Now you can directly write ‘player’ without needing ‘self.’ in front of it. (If you are using a text editor like PyCharm, you should add @staticmethod before vars_for_template and is_displayed to indicate that they are not regular methods.)
- There is no longer any distinction between page methods and model methods. The is_displayed and vars_for_template can freely be moved up into the “FUNCTIONS” section, and reused between pages, or put inside a page class if they only pertain to that class.
- The app folder is simplified from this:
To this:
dictator/
__init__.py
Decide.html
Results.html
Also, the “import” section at the top is simplified.
Before:
# models.py
from otree.api import (
models,
widgets,
BaseConstants,
BaseSubsession,
BaseGroup,
BasePlayer,
Currency as c,
currency_range
)
# pages.py
from otree.api import Currency as c, currency_range
from ._builtin import Page, WaitPage
from .models import Constants
After:
# __init__.py
from otree.api import *
You can see the sample games in the new format here: here.
How does this affect you?¶
This no-self format is only available with oTree Lite. oTree Lite supports both formats. Within the same project, you can have some apps that use the models.py format, and some that use the no-self format.
There is a command “otree remove_self” that can automatically convert the models.py format to the no-self format. This is for people who are curious what their app would look like in the no-self format. Later, I will describe this command and how to use it.
FAQ¶
Q: Do I need to change my existing apps? A: No, you can keep them as is. The “no-self” format is optional.
Q: Will I have to re-learn oTree for this new format? A: No, you don’t really need to relearn anything. Every function, from creating_session, to before_next_page, etc, does the same thing as before. And there are no changes to other parts of oTree like templates or settings.py.
Q: Why didn’t you implement it this way originally? A: The first reason is that oTree got its structure from Django. But now that I made oTree Lite which is not based on Django, I have more freedom to design the app structure the way I see fit. The second reason is that this is quite a tailored design. It was necessary to wait and see how oTree evolved and how people use oTree before I could come up with the most appropriate design.
How to use it¶
First, ensure that you are using oTree Lite:
pip3 install -U otree
Then do one of the following:
- Convert your existing apps using
otree remove_self
, as described in this page. - Create a new project.
There are now 2 branches of the documentation. These docs you are reading now are based on the no-self format (see the note at the top of the page).
Try it out and send me any feedback!
The “otree remove_self” command¶
If you prefer the no-self format, or are curious what your app would look like in this format, follow these steps. First, then install oTree Lite:
pip3 install -U otree
Run:
otree remove_self
otree upcase_constants
Note this command pretty aggressively converts all your model methods to functions,
e.g. changing player.foo()
to foo(player)
.
If you have a lot of custom methods,
you should check that your method calls still work.
Misc notes¶
before_next_page
now takes a second argtimeout_happened
.- You can optionally add a type hint to your function signatures. For example,
change
def xyz(player)
todef xyz(player: Player)
. If you use PyCharm or VS Code, that will mean you get better autocompletion.