12.3 反作弊与刷量

同一批流量里哪些是真玩家、哪些是刷量,是发行端最脏也最值钱的一道分界。本节只用四个公开可观测特征,把「正常 / 重度正常 / 快刷 / 慢刷」四类账号做成可机读配置,用可播种 PCG32 的 P 层内核跨语言对拍,再用 S 层仿真量化精确率与召回率的权衡。

12.2 回答的是「这个地区能不能用」,本节换一个维度:同一批流量里,哪些是真玩家、哪些是刷量。刷量是发行端最常见的作弊形态——渠道刷激活、玩家刷广告奖励、脚本刷签到,直接污染留存与 eCPM 口径。本节不写风控策略,只把「怎么把两类流量分开」做成一条可复现的工程闭环:D 层定义可观测信号与阈值,P 层把判定内核写成可播种的确定性代码,S 层用同一条随机流跑仿真、量化精确率与召回率的权衡。

边界先划清:本节不给风控策略建议,也不复现任何真实风控结论。真实刷量流量与真实风控阈值需要真实业务环境与真实样本,命令行不可复现,标记为 X。下面所有数字都是建模假设(观察时点 2026-10)在可播种仿真下的结果,用来演示「降阈值多抓、代价是误伤」的机制,不代表线上真实分布。这条纪律与出海合规与税务风险清单 里「不把假设当结论」的写法一致。

可观测信号与阈值(D 层)

判据只用四个公开可观测的特征:每分钟事件数 eventsPerMinute(突发速率)、事件间隔的变异系数千分比 intervalCvPermille(越低越机械,正常人手速抖动大)、每千次事件领奖数 claimPermille(脚本倾向于无脑领满)、同一设备指纹下的账号数 accountsPerDevice(设备复用)。把「正常 / 重度正常 / 快刷 / 慢刷」四类账号的特征区间写成可机读配置:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "seed": 20261013,
  "note": "刷量与作弊流量的可播种检测配置。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):把「正常 / 重度正常 / 快刷 / 慢刷」四类账号的行为特征区间与检测阈值整理成可机读配置。cohorts 按顺序消费同一个 PCG32 流(每实体 4 次抽签:rate→cv→claim→devices),故实体序列完全可复现。label=1 为刷量账号(用于算检测率),label=0 为正常账号(用于算误伤率)。eventsPerMinute=每分钟事件数、intervalCvPermille=事件间隔的变异系数千分比(越低越机械)、claimPermille=每千次事件领取奖励数、accountsPerDevice=同一设备指纹下的账号数。detector 四条规则各记 1 分,score ≥ scoreThreshold 即判为刷量。真实刷量流量与真实风控结论需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "cohorts": [
    { "id": "normal", "label": 0, "count": 40, "rateMin": 1, "rateMax": 6, "cvMin": 400, "cvMax": 900, "claimMin": 0, "claimMax": 250, "devicesMin": 1, "devicesMax": 2 },
    { "id": "heavy_legit", "label": 0, "count": 15, "rateMin": 10, "rateMax": 20, "cvMin": 400, "cvMax": 900, "claimMin": 0, "claimMax": 250, "devicesMin": 1, "devicesMax": 2 },
    { "id": "bot_fast", "label": 1, "count": 25, "rateMin": 20, "rateMax": 60, "cvMin": 0, "cvMax": 60, "claimMin": 700, "claimMax": 990, "devicesMin": 1, "devicesMax": 2 },
    { "id": "bot_stealth", "label": 1, "count": 25, "rateMin": 2, "rateMax": 8, "cvMin": 450, "cvMax": 850, "claimMin": 100, "claimMax": 300, "devicesMin": 6, "devicesMax": 40 }
  ],
  "detector": {
    "burstPerMinuteMin": 12,
    "regularityMaxPermille": 150,
    "claimRatePermilleMin": 500,
    "accountsPerDeviceMin": 5,
    "scoreThreshold": 2
  },
  "sweepThresholds": [1, 2, 3]
}

四类里最值得留意的是 bot_stealth(慢刷):它把速率压到 2–8、间隔抖动做到 450–850‰、领奖率降到 100–300‰,单看任何一条都像正常号,唯一暴露点是设备复用 6–40。detector 四条规则各记 1 分,score ≥ scoreThreshold 判为刷量,主阈值取 2。全整数、无浮点。

检测内核:可播种、跨语言对拍(P 层)

内核的关键是确定性:不能用 Math.random(),随机数用可播种 PCG32(BigInt 64 位状态),Python 侧同款实现逐位等价。同一份配置喂给两侧,落盘的混淆摘要必须逐字符一致——这就是「一份配置、两处消费」的验收。

// file: core/src/g12/fraud.ts
// tier: P
// verify: node --test 'core/test/g12_*.test.ts'
//
// 刷量与作弊流量的检测内核:与 sim/g12_fraud_sim.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g12_fraud.json;全整数、无浮点、无 Math.random(),随机数用可播种 PCG32。

export const MULT = 6364136223846793005n;
export const MASK64 = (1n << 64n) - 1n;
export const MASK32 = (1n << 32n) - 1n;

/** PCG32 伪随机数发生器:与 sim/g12_fraud_sim.py 的 Pcg32 逐位等价。 */
export class Pcg32 {
  private state: bigint;
  private readonly inc: bigint;
  constructor(seed: number, seq = 54) {
    this.state = 0n;
    this.inc = ((BigInt(seq) << 1n) | 1n) & MASK64;
    this.u32();
    this.state = (this.state + BigInt(seed)) & MASK64;
    this.u32();
  }
  private u32(): number {
    const old = this.state;
    this.state = (old * MULT + this.inc) & MASK64;
    const xs = (((old >> 18n) ^ old) >> 27n) & MASK32;
    const rot = old >> 59n;
    return Number(((xs >> rot) | (xs << ((32n - rot) & 31n))) & MASK32);
  }
  span(lo: number, hi: number): number { return lo + this.u32() % (hi - lo + 1); }
}

export interface Cohort { readonly id: string; readonly label: number; readonly count: number;
  readonly rateMin: number; readonly rateMax: number; readonly cvMin: number; readonly cvMax: number;
  readonly claimMin: number; readonly claimMax: number; readonly devicesMin: number; readonly devicesMax: number; }
export interface Detector { readonly burstPerMinuteMin: number; readonly regularityMaxPermille: number;
  readonly claimRatePermilleMin: number; readonly accountsPerDeviceMin: number; readonly scoreThreshold: number; }
export interface FraudConfig { readonly seed: number; readonly cohorts: readonly Cohort[];
  readonly detector: Detector; readonly sweepThresholds: readonly number[]; }
export interface Entity { readonly id: string; readonly cohort: string; readonly label: number;
  readonly eventsPerMinute: number; readonly intervalCvPermille: number;
  readonly claimPermille: number; readonly accountsPerDevice: number; score: number; }
export interface Confusion { readonly threshold: number; readonly tp: number; readonly fp: number;
  readonly fn: number; readonly tn: number; readonly precisionPermille: number; readonly recallPermille: number; }

/** 按 cohort 顺序、每实体 4 次抽签(rate→cv→claim→devices),生成可复现的实体序列。 */
export function genEntities(cfg: FraudConfig): Entity[] {
  const rng = new Pcg32(cfg.seed);
  const out: Entity[] = [];
  for (const c of cfg.cohorts) {
    for (let j = 0; j < c.count; j += 1) {
      out.push({ id: `${c.id}-${String(j).padStart(2, "0")}`, cohort: c.id, label: c.label,
        eventsPerMinute: rng.span(c.rateMin, c.rateMax), intervalCvPermille: rng.span(c.cvMin, c.cvMax),
        claimPermille: rng.span(c.claimMin, c.claimMax),
        accountsPerDevice: rng.span(c.devicesMin, c.devicesMax), score: 0 });
    }
  }
  return out;
}

/** 四条规则各记 1 分:突发速率、间隔规律、领取率、设备复用。 */
export function score(e: Entity, det: Detector): number {
  return (e.eventsPerMinute >= det.burstPerMinuteMin ? 1 : 0)
    + (e.intervalCvPermille <= det.regularityMaxPermille ? 1 : 0)
    + (e.claimPermille >= det.claimRatePermilleMin ? 1 : 0)
    + (e.accountsPerDevice >= det.accountsPerDeviceMin ? 1 : 0);
}

/** 在给定阈值下统计混淆矩阵与精确率 / 召回率(千分比,整数地板除)。 */
export function confusion(entities: readonly Entity[], det: Detector, threshold: number): Confusion {
  let tp = 0; let fp = 0; let fn = 0; let tn = 0;
  for (const e of entities) {
    const flagged = score(e, det) >= threshold;
    if (e.label === 1 && flagged) tp += 1; else if (e.label === 1) fn += 1;
    else if (flagged) fp += 1; else tn += 1;
  }
  return { threshold, tp, fp, fn, tn,
    precisionPermille: tp + fp === 0 ? 1000 : Math.floor((tp * 1000) / (tp + fp)),
    recallPermille: tp + fn === 0 ? 1000 : Math.floor((tp * 1000) / (tp + fn)) };
}

/** 跨语言对拍摘要:与 sim/g12_fraud_sim.py 的 digest 逐字符一致(用主阈值)。 */
export function digest(cfg: FraudConfig): string {
  const c = confusion(genEntities(cfg), cfg.detector, cfg.detector.scoreThreshold);
  return `flagged=${c.tp + c.fp},tp=${c.tp},fp=${c.fp},fn=${c.fn},tn=${c.tn},`
    + `precPermille=${c.precisionPermille},recallPermille=${c.recallPermille}`;
}

/** 阈值扫描:展示「降阈值多抓慢刷、代价是误伤重度正常号」的权衡。 */
export function sweep(cfg: FraudConfig): Confusion[] {
  const entities = genEntities(cfg);
  return cfg.sweepThresholds.map((th) => confusion(entities, cfg.detector, th));
}

score 的四条规则与配置一一对应;confusion 用整数地板除算 precisionPermille / recallPermille(千分比),避免浮点。同一个内核在浏览器端与 Node 端跑出的数字必须一样,这正是引擎侧「一份逻辑两处宿主」的延续。

仿真与阈值权衡(S 层)

S 层脚本用同一个 seed 生成 105 个实体(40 正常 + 15 重度正常 + 25 快刷 + 25 慢刷),跑检测器,再对 sweepThresholds 扫描阈值:

#!/usr/bin/env python3
# file: sim/g12_fraud_sim.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g12_fraud_sim.py --config config/ --out out/
"""刷量与作弊流量的可播种检测仿真(S 层)。读 config/g12_fraud.json,用 PCG32(seed=config.seed)
按 cohort 顺序生成四类账号行为特征,四条规则各记 1 分,score ≥ threshold 即判为刷量,统计
tp/fp/fn/tn 与 precision‰/recall‰ 并扫描阈值。PCG32 与 core/src/g12/fraud.ts 逐位等价,实体序列
与摘要完全可复现。真实刷量流量与真实风控结论需真实业务环境,命令行不可复现(X)。
产出 .json/.csv/.svg,全整数无浮点。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

MULT = 6364136223846793005
MASK64 = (1 << 64) - 1
MASK32 = (1 << 32) - 1


class Pcg32:
    """与 core/src/g12/fraud.ts 的 Pcg32 逐位等价的 PCG32(可播种、确定性)。"""

    def __init__(self, seed: int, seq: int = 54) -> None:
        self.state, self.inc = 0, ((seq << 1) | 1) & MASK64
        self._u32()
        self.state = (self.state + seed) & MASK64
        self._u32()

    def _u32(self) -> int:
        old = self.state
        self.state = (old * MULT + self.inc) & MASK64
        xs = (((old >> 18) ^ old) >> 27) & MASK32
        rot = old >> 59
        return ((xs >> rot) | (xs << ((32 - rot) & 31))) & MASK32

    def span(self, lo: int, hi: int) -> int:
        return lo + self._u32() % (hi - lo + 1)


def gen_entities(cfg: dict) -> list[dict]:
    """按 cohort 顺序、每实体 4 次抽签(rate→cv→claim→devices),生成可复现的实体序列。"""
    rng, out = Pcg32(cfg["seed"]), []
    for c in cfg["cohorts"]:
        for j in range(c["count"]):
            out.append({"id": f'{c["id"]}-{j:02d}', "cohort": c["id"], "label": c["label"],
                        "eventsPerMinute": rng.span(c["rateMin"], c["rateMax"]),
                        "intervalCvPermille": rng.span(c["cvMin"], c["cvMax"]),
                        "claimPermille": rng.span(c["claimMin"], c["claimMax"]),
                        "accountsPerDevice": rng.span(c["devicesMin"], c["devicesMax"])})
    return out


def score(e: dict, det: dict) -> int:
    """与 core/src/g12/fraud.ts 的 score 逐位等价的四条规则打分。"""
    return ((e["eventsPerMinute"] >= det["burstPerMinuteMin"])
            + (e["intervalCvPermille"] <= det["regularityMaxPermille"])
            + (e["claimPermille"] >= det["claimRatePermilleMin"])
            + (e["accountsPerDevice"] >= det["accountsPerDeviceMin"]))


def confusion(entities: list[dict], det: dict, threshold: int) -> dict:
    tp = fp = fn = tn = 0
    for e in entities:
        flagged = score(e, det) >= threshold
        if e["label"] == 1 and flagged:
            tp += 1
        elif e["label"] == 1:
            fn += 1
        elif flagged:
            fp += 1
        else:
            tn += 1
    return {"threshold": threshold, "tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precisionPermille": (tp * 1000) // (tp + fp) if (tp + fp) else 1000,
            "recallPermille": (tp * 1000) // (tp + fn) if (tp + fn) else 1000}


def draw_svg(path: str, sweep: list[dict]) -> int:
    """纯 Python SVG:阈值扫描的召回(蓝)与误伤(红)双条。"""
    W, ROW, PAD, BAR = 720, 44, 150, 400
    H = 70 + ROW * len(sweep)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    for i, r in enumerate(sweep):
        y = 30 + i * ROW
        ET.SubElement(svg, "text", {"x": "12", "y": str(y + 20), "font-size": "12",
                                    "fill": "#374151"}).text = f'阈值 ≥{r["threshold"]}'
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "height": "14", "rx": "3",
                                    "width": str(BAR * r["recallPermille"] // 1000), "fill": "#2563eb"})
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 22), "height": "14", "rx": "3",
                                    "width": str(BAR * r["fp"] // max(1, r["tp"] + r["fp"])), "fill": "#dc2626"})
        ET.SubElement(svg, "text", {"x": str(PAD + BAR + 8), "y": str(y + 20), "font-size": "11",
                                    "fill": "#6b7280"}).text = f'召回 {r["recallPermille"]}‰ TP{r["tp"]} · 误伤 {r["fp"]}'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g12_fraud.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    det = cfg["detector"]
    entities = gen_entities(cfg)
    for e in entities:
        e["score"] = score(e, det)
    sweep = [confusion(entities, det, th) for th in cfg["sweepThresholds"]]
    primary = next(r for r in sweep if r["threshold"] == det["scoreThreshold"])
    cohorts = [{"cohort": c["id"], "label": c["label"],
                "count": sum(1 for e in entities if e["cohort"] == c["id"]),
                "flaggedAtPrimary": sum(1 for e in entities if e["cohort"] == c["id"]
                                        and e["score"] >= det["scoreThreshold"]),
                "scoreMin": min(e["score"] for e in entities if e["cohort"] == c["id"]),
                "scoreMax": max(e["score"] for e in entities if e["cohort"] == c["id"])}
               for c in cfg["cohorts"]]
    digest = (f'flagged={primary["tp"] + primary["fp"]},tp={primary["tp"]},fp={primary["fp"]},'
              f'fn={primary["fn"]},tn={primary["tn"]},precPermille={primary["precisionPermille"]},'
              f'recallPermille={primary["recallPermille"]}')
    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g12_fraud_sim.svg"), sweep)
    with open(os.path.join(a.out, "g12_fraud_sim.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "cohort", "label", "eventsPerMinute", "intervalCvPermille",
                    "claimPermille", "accountsPerDevice", "score", "flagged"])
        for e in entities:
            w.writerow([e["id"], e["cohort"], e["label"], e["eventsPerMinute"],
                        e["intervalCvPermille"], e["claimPermille"], e["accountsPerDevice"],
                        e["score"], int(e["score"] >= det["scoreThreshold"])])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "seed": cfg["seed"], "entityCount": len(entities), "detector": det, "primary": primary,
           "sweep": sweep, "cohorts": cohorts, "digest": digest,
           "svg": os.path.join(a.out, "g12_fraud_sim.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "g12_fraud_sim.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "entityCount": len(entities), "primary": primary,
                      "sweep": sweep, "cohorts": cohorts}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

同机实跑(/opt/homebrew/bin/python3,3.14.6)的关键数字如下,全部来自命令行真实输出:

digest = flagged=25,tp=25,fp=0,fn=25,tn=55,precPermille=1000,recallPermille=500
阈值扫描 ≥1 → tp=50 fp=13 recall=1000‰ prec=793‰
阈值扫描 ≥2 → tp=25 fp=0  recall=500‰  prec=1000‰
阈值扫描 ≥3 → tp=25 fp=0  recall=500‰  prec=1000‰
cohort score 区间 normal 0–0 / heavy_legit 0–1 / bot_fast 3–3 / bot_stealth 1–1

这张表就是本节要传达的核心权衡:主阈值 2 下精确率 1000‰(零误伤),但召回只有 500‰——慢刷全漏(25 个 bot_stealth 每个 score 只有 1,只命中设备复用一条规则)。把阈值降到 1,召回拉满 1000‰,代价是误伤 13 个 heavy_legit(重度正常玩家被当成刷量)。这正是反作弊的日常:没有免费的全中,阈值就是在「漏掉多少刷量」和「冤枉多少真玩家」之间选一个可接受的交点。

TypeScript 内核跑 node --test 'core/test/g12_*.test.ts' 6/6 通过,其中「跨语言一致」用例直接断言 digest(cfg) 等于 Python 侧落盘的金标准字符串——两侧判定逻辑等价、可复现。这就是本节的验收标准:不是某条规则多聪明,而是整条链路可重算——换个 seed、加一条规则、调一次阈值,结论都能重新算出来。

阅读导航:上一节:12.2 制裁与地区可用性 · 下一节:13.1 海外渠道 UA 口径 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制