gamealgo-cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ gamealgo key create --name <runtime-name> --json
40
40
  gamealgo key reveal --name <runtime-name> --json
41
41
  ```
42
42
 
43
- 安装和更新 CLI 都使用 `npm install -g gamealgo-cli@latest`,不需要拉取 GameAlgo Client 仓库。`gamealgo docs` 从当前 Admin Host 获取最新 AI 文档;登录前显式传 `--host`,登录后 CLI 会使用已保存的 Host。需要网页阅读时使用 `gamealgo docs --open`。
43
+ 安装和更新 CLI 都使用 `npm install -g gamealgo-cli@latest`,不需要拉取 GameAlgo Client 仓库。请在每个游戏项目根目录执行 `gamealgo login`,凭据保存在项目的 `.gamealgo/cli.json`,不同游戏互不覆盖。`gamealgo docs` 从当前 Admin Host 获取最新 AI 文档;登录前显式传 `--host`,登录后 CLI 会使用已保存的 Host。需要网页阅读时使用 `gamealgo docs --open`。
44
44
 
45
45
  如果已有可用 Client Game Key,就复用或 reveal;如果没有,就创建。Client Game Key 格式为 `ga_live_*`,用于游戏运行时拉配置、拉配置文件、上报事件。
46
46
 
package/cli/README.md CHANGED
@@ -56,7 +56,9 @@ gamealgo login \
56
56
  --admin-key ga_admin_xxx
57
57
  ```
58
58
 
59
- 登录信息会写入本机 `~/.gamealgo/cli.json`,CLI 会把文件权限设置为 `600`。也可以通过环境变量临时传入:
59
+ 请在游戏项目根目录执行登录。登录信息会写入当前目录的 `.gamealgo/cli.json`,CLI 会把文件权限设置为 `600`,并自动忽略整个 `.gamealgo/` 目录,避免密钥被提交到 Git。后续命令会从当前目录逐级向上查找该文件,因此可以在项目子目录执行;不同游戏仓库的登录信息互不覆盖。
60
+
61
+ 旧版的 `~/.gamealgo/cli.json` 仍可作为未找到项目级配置时的兜底。也可以通过环境变量临时传入:
60
62
 
61
63
  ```bash
62
64
  GAMEALGO_ADMIN_HOST=<admin-host>
@@ -227,7 +229,8 @@ type: managed
227
229
  platform: ios
228
230
  objectiveTemplate: ltv_proxy@1
229
231
  baselineVariantId: alpha
230
- cycleDays: 7
232
+ minWindowDays: 7
233
+ maxWindowDays: 14
231
234
  maxVariantsPerRound: 3
232
235
  # 可选:仅托管实验支持。最近 24 小时同时满足两个门槛后才开始首轮。
233
236
  startCondition:
@@ -244,15 +247,17 @@ variants:
244
247
 
245
248
  `minCoverage` 表示指定平台最近 24 小时内,实验接入版本达到 Strategy `requiredIntegrationVersion` 的去重用户占比;`minUsers` 表示同一窗口内的非 Debug 去重用户数。两项可单独配置,同时配置时必须同时满足。等待期间 Strategy 继续下发默认参数,满足门槛后平台才生成首轮时间窗并开始分流。手动实验不支持 `startCondition`,托管实验不配置时仍会立即启动。`experiment run show` 返回 `startConditionSnapshot`,可查看当前用户数、覆盖率和最近检查时间。
246
249
 
247
- 托管实验只执行候选组数量决定的计划轮次,不会因为结果不显著而自动追加轮次。每轮结束都会生成正式报告;最后一轮只有在证据足够明确时才更新默认参数。没有明确胜出组时,实验正常完成,Strategy 默认参数保持不变。
250
+ 托管实验只执行候选组数量决定的计划轮次,不会自动增加复赛轮次。每轮至少运行 `minWindowDays` 天;达到参考周期后若证据仍不明确,平台逐日延长,最长运行到 `maxWindowDays`。`minWindowDays` 支持 1~30 天,`maxWindowDays` 支持到 60 天且不能小于参考周期。达到最长周期仍无明确胜出组时结束本轮,Strategy 默认参数保持不变。
248
251
 
249
252
  `ltv_proxy@1` 的标准口径为:
250
253
 
251
254
  ```text
252
- LTV Proxy = DAU ARPU * (1 + D1 + D2 + D3 + D4 + D5)
255
+ LTV Proxy (Dk) = STANDARDIZED_DAY_VALUE_D0 + ... + STANDARDIZED_DAY_VALUE_Dk
253
256
  ```
254
257
 
255
- 其中 DAU ARPU 是实验窗口内总收入除以活跃用户天数;Dx 留存只使用截至评估时已经成熟的 cohort,并按 cohort 用户数加权。平台不会等待最后一天用户的 D5 成熟后再结束实验。
258
+ `STANDARDIZED_DAY_VALUE_Dx` 使用所有在 Dx 已成熟的实验进入用户作为分母,未回访用户当日价值按 0 计入。广告曝光按广告类型和用户本地日内曝光序号分桶,每个桶使用本轮所有参与组在实验窗口内共同计算的参考 CPM,再加实际内购收入。
259
+
260
+ 所有实验组使用同一个 `k`。平台按参考周期固定 `k 上限 = min(14, minWindowDays - 3)`,再选择不超过上限、且每个参与组都至少有 10 个成熟进入用户的最大连续 Dx。延长只增加样本,不改变评分口径。平台用本轮真实用户和线上 MurmurHash 分流规则模拟大量随机 seed,估计当前游戏用户结构下的自然组间波动,并校正多实验组与逐日重复评估。实际收入、实际 ARPU、新用户留存和新用户 LTV 只作为诊断指标,不参与主指标计算。
256
261
 
257
262
  所有会修改线上状态的实验命令都必须显式传 `--yes`。
258
263
 
package/dist/gamealgo.js CHANGED
@@ -7,7 +7,9 @@ import { homedir } from "node:os";
7
7
  import { createHash } from "node:crypto";
8
8
  import { spawn } from "node:child_process";
9
9
  import YAML from "yaml";
10
- const CONFIG_PATH = join(homedir(), ".gamealgo", "cli.json");
10
+ const GLOBAL_CONFIG_PATH = join(homedir(), ".gamealgo", "cli.json");
11
+ const PROJECT_CONFIG_DIR = ".gamealgo";
12
+ const PROJECT_CONFIG_FILE = "cli.json";
11
13
  const DOCS_CACHE_ROOT = join(homedir(), ".gamealgo", "docs");
12
14
  const SCRIPT_EXTENSIONS = new Set([".js", ".lua"]);
13
15
  const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -135,12 +137,13 @@ async function login(args, global) {
135
137
  if (session.principal.type !== "game_admin" || !session.principal.gameId) {
136
138
  throw new Error("admin key is not scoped to a game");
137
139
  }
138
- await saveConfig({ host, adminKey });
140
+ const configPath = await saveConfig({ host, adminKey });
139
141
  await printResult({
140
142
  ok: true,
141
143
  host,
142
144
  gameId: session.principal.gameId,
143
145
  keyName: session.principal.keyName,
146
+ configPath,
144
147
  }, global);
145
148
  }
146
149
  async function handleDocs(args, global) {
@@ -395,9 +398,20 @@ async function handleExperimentRun(client, args, global) {
395
398
  if (!optionalString(body.baselineVariantId)) {
396
399
  throw new Error("experiment run requires explicit baselineVariantId");
397
400
  }
398
- if ((optionalString(body.type) || "manual") !== "managed" && body.startCondition !== undefined) {
401
+ const runType = optionalString(body.type) || "manual";
402
+ if (runType !== "managed" && body.startCondition !== undefined) {
399
403
  throw new Error("experiment run startCondition is supported only when type is managed");
400
404
  }
405
+ if (runType === "managed") {
406
+ const minWindowDays = Number(body.minWindowDays);
407
+ const maxWindowDays = Number(body.maxWindowDays);
408
+ if (!Number.isInteger(minWindowDays) || minWindowDays < 1 || minWindowDays > 30) {
409
+ throw new Error("managed experiment run requires minWindowDays between 1 and 30");
410
+ }
411
+ if (!Number.isInteger(maxWindowDays) || maxWindowDays < minWindowDays || maxWindowDays > 60) {
412
+ throw new Error("managed experiment run requires maxWindowDays between minWindowDays and 60");
413
+ }
414
+ }
401
415
  await printResult(await client.createExperimentV2Run(strategyKey, body), global);
402
416
  return;
403
417
  }
@@ -1398,18 +1412,59 @@ function requireFlagValue(args, index, flag) {
1398
1412
  return value;
1399
1413
  }
1400
1414
  async function loadConfig() {
1401
- try {
1402
- return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
1415
+ const localPath = await findProjectConfigPath(process.cwd());
1416
+ const paths = localPath && localPath !== GLOBAL_CONFIG_PATH
1417
+ ? [localPath, GLOBAL_CONFIG_PATH]
1418
+ : [GLOBAL_CONFIG_PATH];
1419
+ for (const configPath of paths) {
1420
+ try {
1421
+ return JSON.parse(await readFile(configPath, "utf8"));
1422
+ }
1423
+ catch {
1424
+ // Try the next supported config location.
1425
+ }
1403
1426
  }
1404
- catch {
1405
- return null;
1427
+ return null;
1428
+ }
1429
+ async function findProjectConfigPath(startDir) {
1430
+ let currentDir = startDir;
1431
+ while (true) {
1432
+ const candidate = join(currentDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE);
1433
+ try {
1434
+ await readFile(candidate, "utf8");
1435
+ return candidate;
1436
+ }
1437
+ catch {
1438
+ const parentDir = dirname(currentDir);
1439
+ if (parentDir === currentDir)
1440
+ return null;
1441
+ currentDir = parentDir;
1442
+ }
1406
1443
  }
1407
1444
  }
1408
1445
  async function saveConfig(config) {
1409
- await mkdir(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
1410
- await chmod(dirname(CONFIG_PATH), 0o700).catch(() => undefined);
1411
- await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
1412
- await chmod(CONFIG_PATH, 0o600).catch(() => undefined);
1446
+ const configDir = join(process.cwd(), PROJECT_CONFIG_DIR);
1447
+ const configPath = join(configDir, PROJECT_CONFIG_FILE);
1448
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
1449
+ await chmod(configDir, 0o700).catch(() => undefined);
1450
+ await ensureProjectConfigIgnored(configDir);
1451
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
1452
+ await chmod(configPath, 0o600).catch(() => undefined);
1453
+ return configPath;
1454
+ }
1455
+ async function ensureProjectConfigIgnored(configDir) {
1456
+ const ignorePath = join(configDir, ".gitignore");
1457
+ try {
1458
+ const existing = await readFile(ignorePath, "utf8");
1459
+ const rules = existing.split(/\r?\n/).map((line) => line.trim());
1460
+ if (rules.includes("*") || rules.includes(PROJECT_CONFIG_FILE) || rules.includes(`/${PROJECT_CONFIG_FILE}`))
1461
+ return;
1462
+ const separator = existing && !existing.endsWith("\n") ? "\n" : "";
1463
+ await writeFile(ignorePath, `${existing}${separator}${PROJECT_CONFIG_FILE}\n`, { encoding: "utf8", mode: 0o600 });
1464
+ }
1465
+ catch {
1466
+ await writeFile(ignorePath, "*\n", { encoding: "utf8", mode: 0o600 });
1467
+ }
1413
1468
  }
1414
1469
  async function readObjectFile(filePath, label) {
1415
1470
  const text = await readFile(filePath, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamealgo-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "GameAlgo command-line client for AI agents and game developers",
5
5
  "type": "module",
6
6
  "bin": {