oTree

_images/splash.png

中文 | 日本語

关于

oTree是一个基于python的框架,能够让你构建:

  • 多人策略游戏,如囚徒困境,公共品博弈,拍卖
  • 经济学,心理学等相关领域的受控行为实验
  • 调查问卷与测验

支持

For help, post to our forum.

目录:

安装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 )

我们来创建一个简单的问卷调查——在第一个页面上,将询问参与人的姓名与年龄,之后在第二个页面上,将此信息展示给参与人。

玩家模型

在侧边栏,点击玩家模型。我们来添加两个字段:

  • name (StringField,意为文本型数据)
  • age (IntegerField)

页面

此调查问卷包含2个页面:

  • 页面1:玩家输入姓名与年龄
  • 页面2:玩家看到他们在之前的页面输入的数据

所以,在你的 page_sequence 中创建2个页面:SurveyResults

页面1

首先我们定义 Survey 页面。这个页面包含一个表单,将 form_model 设为 player 并在 form_fields 中选中 nameage

然后,将模板的标题设为 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

下载与运行

下载otreezip文件,并按照提示按照otree即可运行这个otreezip文件。

如果遇到任何问题,你可以在oTree 讨论组 中提问。

第二部分:公共品博弈

(一个视频教程在这里 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_modelform_fields。具体来说,这一表单让你能够设定玩家的 contribution 字段。(查看 表单 以获得更多信息。)

class Contribute(Page):

    form_model = 'player'
    form_fields = ['contribution']

现在我们来创建HTML模板。

设置 titleContributecontent 如下:

<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 }}

页面序列

确保你的page_sequence是正确的:

page_sequence = [
    Contribute,
    ResultsWaitPage,
    Results
]

定义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)

第三部分:信任博弈

现在我们来创建一个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页面的问卷调查:

_images/session_subsession.png

如果一个游戏重复多轮,那么每一轮都是一个子会话。

小组

每个子会话可以被进一步划分为由玩家组成的小组;举例来说,假设你有一个30位玩家的子会话,可划分为每组由2名玩家组成的15个小组。(注意:小组可以在不同子会话中被重新排列。)

对象层级

oTree的实体可以组织成下面的层级:

Session
    Subsession
        Group
            Player
  • 一个会话是一系列子会话
  • 一个子会话包含若干小组
  • 一个小组包含若干玩家
  • 每位玩家通过一系列页面进行游戏

你可以从低层级的对象来访问任意高层级的对象:

player.participant
player.group
player.subsession
player.session
group.subsession
group.session
subsession.session

参与人

在oTree中,术语”玩家”与“参与人”有着不同的含义。参与人与玩家之间的关系与会话与子会话之间的关系一样:

_images/participant_player.png

在某个特定的子会话中玩家是参与人的实例。玩家就像是一个由参与人扮演的临时的“角色”。一个参与人在第一个子会话中可以扮演玩家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)

最小值,最大值,可选项

如何设置字段的 minmax, 或 choices,参考 简单的表单验证

内置字段与方法

玩家,小组与子会话有一些预定义的字段。举例来说, Playerpayoffid_in_group 字段, in_all_rounds()get_others_in_group() 方法。

这些内置的字段与方法列出如下。

子会话

round_number

给出当前是第几轮。仅在应用有多轮时有意义(轮数由 C.NUM_ROUNDS 设置 )。参考 轮次

get_groups()

返回一个包含子会话中所有小组的列表。

get_players()

返回一个包含子会话中所有玩家的列表。

玩家

id_in_group

自动被赋值为从1开始的正整数。在多人游戏中,表示哪位是玩家1,哪位是玩家2,诸如此类。

payoff

玩家在本轮的收益。参考 收益

round_number

给出当前是第几轮。

会话

num_participants

会话中参与人的数量。

vars

参考 会话字段

参与人

id_in_session

会话中参与人的ID。这与玩家的 id_in_subsession 相同。

其他参与人属性与方法

常量

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,
    )

在模板中你就可以像下面这样访问 ab

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

timeout_seconds

参考 超时

等待页面

参考 等待页面

随机页面序列

你可以使用轮数随机化页面顺序。一个例子在 这里

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_appsapp_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页面:

_images/template-example.png

注意浏览器永远不会收到模板标签。

要点

如果页面没有如期望正常工作,你可以分开来看上面两步中哪一步出错了。在浏览器中,右键单击并“查看源代码”。(注意:“查看源代码”在分离屏幕模式下可能无法正常工作。)

你可以看到生成的纯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>

获取更多信息,可在浏览器中右键单击你想要修改的元素并选择“审查元素”。然后你可以导航到不同元素并试着改变它们的样式:

_images/dom-inspector.png

尽可能使用上面给出的官方选择器。不要使用任何以 _otree 开头或者基于Bootstrap类的选择器,如 btn-primarycard,因为它们是不稳定的。

从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,一个流行的用于定制网站用户界面的库。

如果你想要 定制样式,或者 特定组件 如表格,警告,进度条,标签,等等,你可以使用此库。你甚至可以让你的页面变得动态,通过使用组件如 popoversmodals, 和 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> 标签。

一旦你的图表加载正常,你就可以将硬编码的数据替换为 seriescategories 这样动态生成的变量。

例如,将下面的代码:

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 中定义的变量。

如果你的图表没有加载,在浏览器中单击“查看源码”检查是否是动态生成的数据出现了问题。

杂项

你可以使用 to2to1, 或 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_modelform_fields:

class Page1(Page):
    form_model = 'player'
    form_fields = ['name', 'age'] # this means player.name, player.age

当用户提交表单时,所提交的数据自动保存到模型对应的字段中。

模板中的表单

在模板中,你可以像下面这样显示表单:

{{ formfields }}

简单的表单验证

最小值与最大值

验证一个整数在12到24之间:

offer = models.IntegerField(min=12, max=24)

如果最大值/最小值不是固定的,应当使用 {field_name}_max()

选项

如果你想让字段表现为一个有一系列选项的下拉菜单,设置 choices=:

level = models.IntegerField(
    choices=[1, 2, 3],
)

使用单选按钮而不是下拉菜单,应当设置 widgetRadioSelectRadioSelectHorizontal:

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。

你可以这样设置 BooleanFieldStringField,等等:

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月)中新引入。

可选字段

如果一个字段是可选的,你可以像下面这样设置 blank=True

offer = models.IntegerField(blank=True)

动态表单验证

上述的 minmax,与 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']

控件

你可以设定一个模型字段的 widgetRadioSelectRadioSelectHorizontal 如果你想让选项表现为单选按钮而不是下拉菜单的话。

{{ 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 &nbsp;
    {{ for choice in form.pizza }}
        {{ choice }}
    {{ endfor }}
    &nbsp; 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为 radiotextnumber,与 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(), 并且你希望像下面这样展示按钮,而不是一个单选按钮:

_images/yes-no-buttons.png

首先,将 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”选项卡,并检查每一轮中的 groupid_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...
按照到达时间组队

参考 group_by_arrival_time

等待页面

当一位玩家需要等待其他玩家进行一些操作之后所有玩家才能继续进行游戏时,等待页面是必须的。举例来说,在最后通牒博弈中,玩家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_displayedgroup_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_secondsbefore_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 来代替用户决定所要提交的值。

定制等待页面的外观

你可以定制出现在等待页面上的文本,通过设定 title_textbody_text 即可,举例如下:

class MyWaitPage(WaitPage):
    title_text = "Custom title text"
    body_text = "Custom body text"

也可参考: 自定义等待页面模板

聊天

你可以在页面上添加一个聊天室,使得参与者之间可以互相交流。

基本用法

在你的模板HTML中,放入:

{{ chat }}

这会创建一个同组玩家间的聊天室,玩家的昵称为“玩家1”,“玩家2”等等。(基于玩家的 id_in_group)。

定制昵称与聊天室成员

你可以像下面这样指定 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 }}
例子:跨轮次的聊天

如果你需要玩家可以与不在同一轮的其他玩家聊天,你可以这样做:

{{ chat channel=group.id_in_subsession }}
例子:所有轮次所有小组玩家一起聊天

如果你想让会话中的所有玩家可以自由地与其他人交流,可以这样做:

{{ chat channel=1 }}

(数字1并没有特殊含义,关键在于这一值对所有人相同)

高级自定义

如果你在浏览器中查看了页面的源码,你会发现一系列以 otree-chat__ 开头的类。

你可以使用CSS或JS改变这些元素的外观或者行为(或者将它们完全隐藏起来)。

你也可以通过将其放入一个 <div> 并调整这个父 <div> 的样式来定制外观。例如像下面这样设置宽度:

<div style="width: 400px">
    {{ chat }}
</div>

同一页面上多个聊天

你可以在同一页面上放置多个 {{ chat }} ,这样一名玩家就可以同时处于多个频道中。

将聊天日志导出为CSV

聊天日志的下载链接在oTree的常规数据导出页面。

参见 实验者与参与人聊天

应用&轮次

应用

一个oTree应用就是一个包含Python与HTML代码的文件夹。一个项目包含多个应用。一个会话基本上就是一个接一个被游玩的应用序列。

组合应用

你可以通过设置session config的 'app_sequence' 来组合应用。

在应用之间传递数据

参考 participant fieldssession fields

轮次

你可以通过设置 C.NUM_ROUNDS 来使一个游戏进行多轮。举例来说,如果你的session config中的 app_sequence['app1', 'app2'],同时 app1NUM_ROUNDS = 3app2NUM_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对象的列表,其中代表了同一参与人第 mn 轮的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: ;如果是,那就使用它;如果否,就随机选择一个。

配置会话

你可以使你的会话可配置,这样你就可以在管理员界面调整游戏的参数。

_images/edit-config.png

举例来说,假定你有一个“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 的方法,使得你可以让超时取决于 playerplayer.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_arrivecreating_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):
        ...

自定义计时器

隐藏计时器

如果你希望隐藏计时器,使用下面的CSS:

.otree-timer {
    display: none;
}
改变计时器的行为

计时器的功能由 jQuery Countdown 提供。你可以通过jQuery的 .on()off() 添加和去除事件处理器来改变它的行为。

oTree为 update.countdownfinish.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.playerself.groupself.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. 一位用户标记了一个方格,故你需要通知其他玩家
  2. 一位用户载入(或重载)了页面,故你需要将当前的局面发给他们。

对于情况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服务器上。原因可能包括:

  • 你的实验室没有网络
  • 你想要完全控制服务器的配置
  • 你想要更好的性能(局域网服务器有更低的延迟)

安装 apt-get 包

运行:

sudo apt-get install python3-pip git

创建一个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

在服务器上重置数据库

cd 到包含oTree项目的文件夹。安装依赖并重置数据库:

pip3 install -r requirements.txt
otree resetdb

运行服务器

测试生产服务器

在你的项目文件夹,运行:

otree prodserver 8000

然后在浏览器中导航至你的服务器的IP/主机名并以 :8000 结尾。

如果你没有使用反向代理如Nginx 或 Apache,你可能想直接将oTree运行在80端口上。这需要超级管理员权限,所以要使用sudo,但需添加一下额外的参数来保存环境变量如 PATHDATABASE_URL,等等:

sudo -E env "PATH=$PATH" otree prodserver 80

再打开你的浏览器;这一次,你无需在URL后添加 :80 ,因为这是HTTP默认的端口。

区别于 devserverprodserver 不会在你的文件改变时自动重启。

设置其余的环境变量

将下面这些添加到与你设置 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 命令。然而,如果多人需要同时运行实验,那么你需要将服务器运行在不同的端口上,例如 80008001,等等。

Windows服务器(高级)

如果你只是在你的个人电脑上测试你的应用,你可以使用 otree zipserverotree devserver。你不需要上述完整的服务器设置,但这对与他人分享你的应用是必须的。

此部分适用于对设置web服务器有经验的人。如果你想要更简单和快速的方式,我们推荐使用 Heroku

为什么我需要安装服务器软件?

oTree的开发服务器 (devserver) 并非被设计用于运行实际的实验。

数据库 (Postgres)

安装Postgres 与 psycopg2,创建一个新数据库并设置 DATABASE_URL 环境变量,例如设为 postgres://postgres@localhost/django_db

resetdb

如果上述步骤正确进行,你应当可以运行 otree resetdb

运行生产服务器

运行:

otree prodserver 80

完整的说明见 这里 。步骤与Linux上的完全一样。

设置环境变量

你应当设置 OTREE_ADMIN_PASSWORDOTREE_PRODUCTIONOTREE_AUTH_LEVEL

管理员

oTree的管理员界面允许你创建会话,监控会话,并从会话中导出数据。

打开你的浏览器,网址为 localhost:8000 或你的服务器的URL。

密码保护

当你首次安装oTree后,整个管理员界面是不需要密码访问的。然而,当你准备向用户部署时,你应当用密码保护管理员界面。

如果你发起实验时想让访问者仅可通过所提供的开始链接进行游戏,将环境变量 OTREE_AUTH_LEVEL 设为 STUDY

将你的网站上线并设为公开demo模式,也就是说任何人都可以游玩你游戏的demo版本(但不能完全访问管理员界面),设置 OTREE_AUTH_LEVELDEMO

通常管理员的用户名是”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内置不同的定制的报酬页面

下面是屏幕截图:

_images/admin-report.png

下面是一个简单的例子,我们添加一个管理员报告,它展示了某一给定轮次的排序过的收益列表。

首先,定义一个函数 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>

注意:

  • subsessionsessionC 会被自动传递给模板。
  • 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信息会被显示在屏幕的底部。

报酬

如果你定义了一个名为 finishedparticipant field,那么你可以在参与人完成会话之后设置 participant.finished = True,这会在报酬页面等不同地方显示。

实验者与参与人聊天

为让参与人可以向你发送聊天信息,考虑使用类似 Papercups 的软件。点击”Deploy to Heroku”按钮一键部署你的Papercups服务器。填写必需的配置参数,余下可以不填。BACKEND_URLREACT_APP_URL 指的是你的Papercups站点,不是oTree站点。登录你的网站并复制HTML内嵌代码至一个可包含的模板,命名为 papercups.html这里 有一个名为”chat with experimenter”的例子。

房间

oTree允许你配置”房间”,它提供了下面的特性:

  • 在session之间保持不变的链接,你可以将其分发给参与人或者实验室电脑
  • “等待房间”允许你查看哪些参与者目前正在等待session开始。
  • 易于参与人输入的短链接,利于快速现场演示。

下面是屏幕截图:

_images/room-combined.png

创建房间

你可以创建多个房间——比如说,对应你教学的不同班级,你管理的不同实验室。

如果使用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,参与人会被要求输入他们的参与人标签:

_images/room-combined.png

如果不使用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传递参与人的数据

参见 “Participant vars for room” endpoint

货币

在许多实验中,参与者为了某种货币进行游戏:可能是真实的钱,也可能是点数。oTree两种都支持;你可以通过在 settings.py 中设置 USE_POINTS = False 将点数改为真实的钱。

你可以使用 cu(42) 来表示“42单位货币”。它就像一个数字一样(例如 cu(0.1) + cu(0.2) == cu(0.3))。好处是当展示给用户时,它会自动格式化为 $0.300,30 ,等等。这取决于你的 REAL_WORLD_CURRENCY_CODELANGUAGE_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_POINTSTrue),再加上 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 除以 33 。所以,我们推荐使用足够大数量级的点数,这样就不用担心舍入的错误。举例来说,将某个游戏的捐赠设为1000点,而不是100。

MTurk & Prolific

MTurk

总览

oTree提供与Amazon Mechanical Turk (MTurk)的集成:

  1. 从oTree的管理员界面,你可以将你的会话发布至MTurk。
  2. Mechanical Turk上的worker可以参与你的会话。
  3. 从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> 内部的内容,但注意下面的事项:

  1. 实验的链接应当如 <a class="otree-link">Link text</a>。一旦用户接受了任务,oTree会自动为这些链接添加 href 使它们指向你的实验。
  2. 如果你想要完成代码显示在oTree管理员页面(Payments选项卡),你需要一个名为 completion_code<crowd-input>

让你的会话在MTurk上运行

在你的实验的最后一个页面,给用户一个完成代码。例如,你可以简单地显示:“你已经完成了实验。你的完成代码是TRUST2020。”如果你愿意,你可以生成唯一的完成代码。你无需过于担心完成代码,因为oTree追踪了每位worker的MTurk ID并将其显示在管理员页面上,同时显示他们是否到达了最终页面。完成代码只是一层额外的验证,它给与了worker一个特定的他们习惯于有的目标。

不使用oTree Studio用户的额外步骤

如果你不使用oTree Studio,你需要按照 这里 的步骤操作。

本地沙箱测试

在开始实验之前,你必须创建一个MTurk雇主账户,获取你的 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY

You can obtain these credentials in your AWS Management Console.

为了在进行本地MTurk沙箱测试,查看实验展示给worker的样子,你需要将这些证书存储进你的电脑。

如果使用Windows,在控制面版里搜索“环境变量”,并像下面这样创建两个环境变量:

_images/env-vars.png

在Mac上,将你的证书像这样放入你的 ~/.bash_profile 文件:

export AWS_ACCESS_KEY_ID=AKIASOMETHINGSOMETHING
export AWS_SECRET_ACCESS_KEY=yoursecretaccesskeyhere

重启你的命令行窗口并运行oTree。在oTree管理员界面,点击“Session”,在按钮 “Create New Session”下,选择”For MTurk”:

_images/create-mturk-session.png

在你的web服务器上设置环境变量

如果你使用Heroku,前往你的App Dashboard的”settings”界面,并设置config vars AWS_ACCESS_KEY_IDAWS_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,你可能无需按照本页面的步骤操作。

Prolific

If you’re using Prolific, we recommend setting up oTree HR, which will automatically handle start links, completion URLs, and payments.

杂项

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)
参数
  • session_config_name (必要)
  • num_participants (必要)
  • modified_session_config_fields: 一个可选的包含session config参数的字典,详见 配置会话
  • room_name 如果你想在房间中创建session。

“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.

Writing your app in multiple languages

You may want your own app to work in multiple languages. For example, let’s say you want to run the same experiment with English, French, and Chinese participants.

For an example, see the “multi_language” app here.

提示与技巧

避免代码重复

尽可能避免复制粘贴同一段代码到多个地方是很好的习惯。尽管有时需要一些思考才能明白如何避免复制粘贴代码,但是将代码只重复一次通常会在你需要更改设计或者修复错误时为你节省很多时间和精力。

下面是一些实现代码复用的技巧。

不要将你的应用复制多份

你应当尽可能避免复制你的应用文件夹来创建一个有细微差别的版本,因为此时重复代码很难维护。

如果你需要多轮游戏,可通过设置 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_arrivelive_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_texttitle_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,但同时提交了非法的 weightheight:

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']
)

它会查对出 weightheight 包含错误,但是 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 to data-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 pass objects=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) to forminputs
  • 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 their id attribute with id_. If you use getElementById/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 for Currency. c() will still work as long as you have from 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

实时页面

See 实时页面.

REST API

See REST

Custom data export

See 自定义数据导出.

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 use prodserver (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 and zipserver now must use db.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

  1. “self” is totally gone from your app’s code.
  2. Whenever you want to refer to the player, you write player. Same for group and subsession.
  3. Each method in oTree is changed to a function.
  4. There is no more models.py and pages.py. The whole game fits into one file (__init__.py).
  5. 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?

  1. 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.
  2. 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.)
  3. 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.
  4. 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:

  1. Convert your existing apps using otree remove_self, as described in this page.
  2. 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 arg timeout_happened.
  • You can optionally add a type hint to your function signatures. For example, change def xyz(player) to def xyz(player: Player). If you use PyCharm or VS Code, that will mean you get better autocompletion.

Indices and tables

中文文档由matrixlt翻译 <https://github.com/matrixlt>