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 +1 -1
- package/cli/README.md +10 -5
- package/dist/gamealgo.js +66 -11
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
+
托管实验只执行候选组数量决定的计划轮次,不会自动增加复赛轮次。每轮至少运行 `minWindowDays` 天;达到参考周期后若证据仍不明确,平台逐日延长,最长运行到 `maxWindowDays`。`minWindowDays` 支持 1~30 天,`maxWindowDays` 支持到 60 天且不能小于参考周期。达到最长周期仍无明确胜出组时结束本轮,Strategy 默认参数保持不变。
|
|
248
251
|
|
|
249
252
|
`ltv_proxy@1` 的标准口径为:
|
|
250
253
|
|
|
251
254
|
```text
|
|
252
|
-
LTV Proxy
|
|
255
|
+
LTV Proxy (Dk) = STANDARDIZED_DAY_VALUE_D0 + ... + STANDARDIZED_DAY_VALUE_Dk
|
|
253
256
|
```
|
|
254
257
|
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
await
|
|
1412
|
-
await chmod(
|
|
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");
|