跳到主要内容
游戏周边服务

本页尚未翻译,当前显示英文版本。

Auth & Players

登录是可以覆盖的步骤,而不是黑盒。 提供方、会话、身份关联、封禁。流程中的每个点——登录前后、关联前后、合并前后、状态变更时——都是声明过的扩展点,并声明了类型:拦截型 hook 可以拒绝这一步,观察型 hook 不能。

何时使用

  • 玩家(player)需要登录(设备、邮箱、Apple、Google、Steam 或自定义方式),首次登录时创建玩家只是一个标志,不是另一套流程。
  • 游客账号(account)之后要升级:Link 添加 Steam 且保留进度,合并(merge)则把两个账号归并为一个玩家。
  • 策略必须在无法绕过的位置执行:登录前检查地区,创建玩家的那次登录(sign-in)之后发放新手礼包。
  • 管理操作必须立即生效:吊销会话、暂停账号、封禁设备,并发出所有在线系统都能收到的 banned 事件。
  • 声明的上下文(地区、平台、build)必须送达后续每个 hook,而不必让每个 hook 重新读取玩家。
  • 没有更轻量的替代方案。 其他所有模块都通过本模块识别调用方;只要其中任何一个模块还需要玩家主体,就无法关闭 auth:模块配置器会拒绝,并列出依赖它的模块。

分工

主体在本页中的职责
player登录,关联或解除关联身份,刷新凭据,登出
moderator吊销会话;封禁、暂停或恢复玩家
backend-service按地区拦截登录;为新玩家写入初始数据行;读取和吊销会话

概览

每个提供方一次 SignIn 调用,首次登录时创建玩家只是一个标志;Link 添加 Steam
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam);          // one player, many identities
// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam);    // one player, many identities
# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM)     # one player, many identities
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
    TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
    {
        if (!Result.HasValue()) { return; }
        // one player, many identities — add Steam to the same account
        Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型原样运行
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam);          // one player, many identities

每个点都在声明它的位置定制;处理器可以采用的各种形式汇总在 Extensibility 中:

登录前的拦截型 hook 拒绝某个地区;创建玩家的那次登录之后,观察型 hook 发放新手礼包
[Before(Auth.SignIn)]                          // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
    a.Region == "sanctioned"
        ? Hook.Reject(Problem.Forbidden, "region not served")
        : Hook.Continue(a);

[After(Auth.SignIn, created: true)]            // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("chest.gold", count: 1);
}
// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
  a.region === 'sanctioned'
    ? Hook.reject(Problem.forbidden, 'region not served')
    : Hook.continue(a));

// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('chest.gold', { count: 1 });
  });
@before(auth.sign_in)                          # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
    if a.region == "sanctioned":
        return Hook.reject(Problem.FORBIDDEN, "region not served")
    return Hook.continue_(a)

@after(auth.sign_in, created=True)             # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
    await player.inventory.grant("chest.gold", count=1)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

在引擎之外运行

覆盖和 hook 都是云函数(cloud function):它们在平台上执行,而不是在引擎中。用 C#、TypeScript 或 Python 编写,Unreal 订阅由此产生的事件。引擎内编写的函数已在计划中:Blueprint 图表,以及 C++(转译为平台验证的脚本目标)。两者都会像其他声明一样经过分析和推送,执行仍在平台上。

在引擎之外运行

覆盖和 hook 都是云函数:它们在平台上执行,而不是在引擎中。用 C#、TypeScript 或 Python 编写,Unity 订阅由此产生的事件。

管理面板展示的就是声明的形式:每个点列出它的处理器、处理器类型和最终的执行顺序。处理器本身失败时会发生什么,由类型决定:

类型处理器本身失败时拒绝时
拦截型 hook(gate)拒绝这一步——地区检查无法执行,不等于地区检查已通过返回平台目录中的一个代码,外加一条给人看的原因。调用方根据代码分支;原因文本可以随时修改,也可以翻译
观察型 hook(observer)这一步仍然完成,所以新手礼包没发出去只是少了一个宝箱,登录不受影响不能拒绝

hook 从不决定谁登录了

拦截型 hook 只针对平台已经确认的身份回答“是”或“否”。它不指定玩家、不分配身份,也不能代替提供方的确认。

模型

device · subject (the guest)Steam · subject (via Link)another player, same persona player — one stable id, never reused linklink: anonymous → registered, one waymerge: terminal; its id keeps resolving
关联由提供方 · 主体标识 · 玩家构成:关联让游客单向升级,合并后被合并玩家的 id 仍可解析

玩家是身份(identity)的持有者,而不是你 schema 中的一行,其标识符稳定且永不复用——合并时也是如此:被合并玩家的 id 仍可解析,不会变成悬空引用。关联(link)是一个三元组:提供方(provider) · 外部主体标识 · 玩家。

始终成立含义
提供方 + 主体标识唯一;这种唯一性带来的是冲突,而不是禁止:遇到“该账号已被占用”,解决办法是选择合并
每个玩家在每个提供方下最多一个关联同一提供方的第二个账号属于冲突
外部主体标识永远不作为玩家标识符:它属于提供方,拿来当作我们的标识符会把我们的 id 和对方的绑死
身份类型与访问状态是两个不同的维度:anonymous 与 registered 是一个维度;active / suspended / banned 是另一个,所以“已封禁的匿名玩家”和“已暂停的注册玩家”都能表达
会话与凭据是两回事:会话(session)是记录,凭据(credential)是你出示的东西。吊销会话会让它的全部凭据失效,并关闭它打开的订阅(subscription)
多个同时存在的会话各自独立吊销
凭据中的 claim是声明的上下文——地区、语言区域——而且仅仅是上下文。claim 从不携带权限
设备指纹不是身份:它从来不是放行的依据,只能作为拒绝的依据,并以不可逆的形式存储和比较

三个状态机。

对象状态
身份类型anonymous → registered,转换是单向的
访问状态active ⇄ suspended;解封时为 active → banned → active
玩家alive → merged,其中 merged 是终态:被合并的玩家不会再登录

使用方声明的内容。

声明项含义
登录策略是否允许匿名登录,以及其他登录规则。以模块挂载点(mount point)上的特性声明——不是代码旁的配置文件,也不是在运行时构造
默认角色新玩家首次登录时拥有的角色(role)集合。默认值本身没有默认值:什么都不声明,新玩家就不带任何角色,这是合法的声明,而不是遗漏
会话策略达到同时会话上限时怎么办——淘汰最早的会话并发出事件(event),或拒绝新会话。没有默认值
删除策略删除玩家时,如何处理引用该玩家的数据

在哪里配置提供方。 在运营平面,而不是在代码中——商店凭据不该放进代码仓库。声明的内容会同步到管理控制台,仅供读取。

授予角色意味着什么。 角色不只是运营方的事:接口为玩家提供了授予和撤销,所以游戏可以在自己的代码中把玩家提升为公会军官,或把权力交给锦标赛主持人。

始终成立含义
不能自我提权授予角色需要为此声明的原子权限(permission atom),没有该原子权限的主体会直接收到 forbidden,而不是静默地什么也不做
授予是幂等的授予玩家已有的角色算成功,不算冲突:状态是角色集合,而不是调用历史,所以与登录不同,这个操作不需要幂等键
角色变更不是即时的变更无需重新签发凭据就会生效,最迟在权限缓存声明的过期上限内可见——所以授予角色后立刻在已连接客户端上检查的代码,必须把这个时间窗口考虑进去

角色由什么组成、能解锁什么,见 Access & Roles。

错误

  • 没有凭据或凭据已过期时返回 not authenticated,刷新即可解决。已吊销的凭据返回同样的结果,但刷新无济于事:只能重新登录。
  • 再次出示已轮换的凭据属于冲突——正因如此,轮换才能检测出来,而不是默默放过。
  • 已封禁或已暂停的玩家,以及已封禁的设备指纹,返回 forbidden,重试没有意义。
  • 提供方 + 主体标识已被占用属于冲突,选择合并后可以重试;同一提供方的第二个账号属于冲突,重试也不会改变结果。
  • 解除最后一种登录方式的关联属于校验拒绝:否则会留下一个谁都无法登录的账号。
  • 合并已被合并的玩家属于冲突——merged 是终态。
  • 提供方不可用时返回 unavailable,值得以退避方式重试;提供方拒绝凭据时返回 not authenticated,值得重试一次,但不要循环重试。
  • 超出登录尝试频率时返回限流类别的错误,并附带截止时间。

限制

具体数值会随平台限制一章给出;触及每个上限时的行为现在已经确定。

  • 每个玩家的同时会话数:按声明的策略,淘汰最早的会话并发出事件,或拒绝新会话。没有默认值。
  • 每个周期的登录尝试次数,以及关联已占用组合的尝试次数:带截止时间的限流,尝试计数会保留在历史中。
  • 每个玩家的关联数:再关联一个提供方时,平台以冲突拒绝。
  • 凭据的有效期:返回 not authenticated,刷新后可重试。刷新凭据的有效期:只能重新登录。
  • 从未登录的匿名玩家的保留期:按声明的策略删除,并发出事件。策略必须显式声明,没有默认值。
  • 设备指纹封禁列表的条目数:拒绝新增,旧条目永远不会静默淘汰。