koishi-plugin-ai-puzzle 0.0.13 → 0.0.15
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/lib/game.d.ts +21 -0
- package/lib/index.js +309 -6
- package/lib/types.d.ts +19 -0
- package/package.json +1 -1
- package/templates//346/270/270/346/210/217/347/214/234/350/260/234.yml +83 -83
package/lib/game.d.ts
CHANGED
|
@@ -59,5 +59,26 @@ export declare class GameOrchestrator {
|
|
|
59
59
|
/** 尝试用 OneBot 合并转发发送结果,成功返回 true,失败返回 false */
|
|
60
60
|
private sendForwardOrFallback;
|
|
61
61
|
private getHistory;
|
|
62
|
+
private pvpStates;
|
|
63
|
+
/** 创建或开始 PVP 竞技 */
|
|
64
|
+
pvp(session: Session, puzzleCount?: number): Promise<string>;
|
|
65
|
+
/** 玩家加入竞技 */
|
|
66
|
+
joinPvp(session: Session): string;
|
|
67
|
+
/** 检查用户是否在 PVP 竞技中 */
|
|
68
|
+
isInPvp(channelId: string, userId: string): boolean;
|
|
69
|
+
/** 处理 PVP 猜测 */
|
|
70
|
+
pvpProcessGuess(session: Session, guessText: string): Promise<string | null>;
|
|
71
|
+
/** 完成谜题生成,若有失败则进入 retry 状态询问组织者 */
|
|
72
|
+
private finishPvpGeneration;
|
|
73
|
+
/** 处理组织者的重试响应(在 retry 状态下) */
|
|
74
|
+
handlePvpRetry(channelId: string, userId: string, text: string): Promise<string | null>;
|
|
75
|
+
private generatePvpPuzzles;
|
|
76
|
+
private startPvpGame;
|
|
77
|
+
private pvpThrowClue;
|
|
78
|
+
private pvpScheduleClue;
|
|
79
|
+
private pvpCheckAllGuessed;
|
|
80
|
+
private pvpPenalize;
|
|
81
|
+
private pvpNextPuzzle;
|
|
82
|
+
private pvpEnd;
|
|
62
83
|
private updateHistory;
|
|
63
84
|
}
|
package/lib/index.js
CHANGED
|
@@ -36,7 +36,7 @@ var require_package = __commonJS({
|
|
|
36
36
|
module2.exports = {
|
|
37
37
|
name: "koishi-plugin-ai-puzzle",
|
|
38
38
|
description: "使用AI生成猜谜谜题,支持多模式游戏、模糊匹配、图片渲染",
|
|
39
|
-
version: "0.0.
|
|
39
|
+
version: "0.0.15",
|
|
40
40
|
main: "lib/index.js",
|
|
41
41
|
typings: "lib/index.d.ts",
|
|
42
42
|
files: [
|
|
@@ -80,7 +80,7 @@ var require_package = __commonJS({
|
|
|
80
80
|
// src/locales/zh-CN.yml
|
|
81
81
|
var require_zh_CN = __commonJS({
|
|
82
82
|
"src/locales/zh-CN.yml"(exports2, module2) {
|
|
83
|
-
module2.exports = { commands: { "ai-puzzle": { description: "AI猜谜游戏", usage: "子命令:\nstart 触发猜谜\nswitch 切换提示词模板\nmanual 切换手动模式\nauto 切换自动模式\nopen 切换开放模式\nrestrict 切换限制模式\nnext 获取下一条线索\nguess 提交猜测\nstop 结束游戏\npre 切换预生成模式\nlist 列出提示词模板\nadd 添加用户出题\ndel 删除用户出题" }, "ai-puzzle.start": { description: "触发AI猜谜游戏", options: { extension: "额外规则参数", userPuzzle: "用户出题代号" } }, "ai-puzzle.switch": { description: "切换提示词模板", arguments: { keyword: "模板关键词" } }, "ai-puzzle.manual": { description: "切换手动模式", arguments: { interval: "最少时间间隔(秒)" } }, "ai-puzzle.auto": { description: "切换自动模式", arguments: { interval: "自动时间间隔(秒)", extraDelay: "额外延迟(秒)" } }, "ai-puzzle.open": { description: "切换开放模式" }, "ai-puzzle.restrict": { description: "切换限制模式", arguments: { perPuzzle: "每个谜题可猜测次数", perClue: "每条线索可猜测次数" } }, "ai-puzzle.next": { description: "获取下一条线索" }, "ai-puzzle.guess": { description: "提交猜测", arguments: { answer: "猜测的答案" } }, "ai-puzzle.stop": { description: "结束游戏" }, "ai-puzzle.pre": { description: "切换预生成模式" }, "ai-puzzle.list": { description: "列出提示词模板" }, "ai-puzzle.add": { description: "添加用户出题", arguments: { code: "谜题代号" } }, "ai-puzzle.del": { description: "删除用户出题", arguments: { code: "谜题代号" } } } };
|
|
83
|
+
module2.exports = { commands: { "ai-puzzle": { description: "AI猜谜游戏", usage: "子命令:\nstart 触发猜谜\nswitch 切换提示词模板\nmanual 切换手动模式\nauto 切换自动模式\nopen 切换开放模式\nrestrict 切换限制模式\nnext 获取下一条线索\nguess 提交猜测\nstop 结束游戏\npre 切换预生成模式\nlist 列出提示词模板\nadd 添加用户出题\ndel 删除用户出题" }, "ai-puzzle.start": { description: "触发AI猜谜游戏", options: { extension: "额外规则参数", userPuzzle: "用户出题代号" } }, "ai-puzzle.switch": { description: "切换提示词模板", arguments: { keyword: "模板关键词" } }, "ai-puzzle.manual": { description: "切换手动模式", arguments: { interval: "最少时间间隔(秒)" } }, "ai-puzzle.auto": { description: "切换自动模式", arguments: { interval: "自动时间间隔(秒)", extraDelay: "额外延迟(秒)" } }, "ai-puzzle.open": { description: "切换开放模式" }, "ai-puzzle.restrict": { description: "切换限制模式", arguments: { perPuzzle: "每个谜题可猜测次数", perClue: "每条线索可猜测次数" } }, "ai-puzzle.next": { description: "获取下一条线索" }, "ai-puzzle.guess": { description: "提交猜测", arguments: { answer: "猜测的答案" } }, "ai-puzzle.stop": { description: "结束游戏" }, "ai-puzzle.pre": { description: "切换预生成模式" }, "ai-puzzle.list": { description: "列出提示词模板" }, "ai-puzzle.add": { description: "添加用户出题", arguments: { code: "谜题代号" } }, "ai-puzzle.del": { description: "删除用户出题", arguments: { code: "谜题代号" } }, "ai-puzzle.pvp": { description: "PVP竞技模式", arguments: { count: "谜题数量(1-20,默认10)" } }, "ai-puzzle.join": { description: "加入PVP竞技" } } };
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
|
|
@@ -444,7 +444,7 @@ var PromptManager = class {
|
|
|
444
444
|
name: parsed["名字"],
|
|
445
445
|
enName: parsed["副名字"] || "",
|
|
446
446
|
aliases: Array.isArray(parsed["别名"]) ? parsed["别名"].filter(Boolean) : [],
|
|
447
|
-
description: parsed["
|
|
447
|
+
description: parsed["介绍"] || "",
|
|
448
448
|
clues: clues.map((c) => String(c)),
|
|
449
449
|
explanations: Array.isArray(parsed["线索解释"]) ? parsed["线索解释"].map((e) => String(e || "")) : clues.map(() => "")
|
|
450
450
|
};
|
|
@@ -715,6 +715,10 @@ var GameOrchestrator = class {
|
|
|
715
715
|
async startGame(session, extensionRule, userPuzzleCode) {
|
|
716
716
|
const channelId = session.channelId;
|
|
717
717
|
this.logger.debug("[Game] startGame: channel=%s, hasExtension=%s, userPuzzle=%s", channelId, !!extensionRule, userPuzzleCode || "(无)");
|
|
718
|
+
const pvpState = this.pvpStates.get(channelId);
|
|
719
|
+
if (pvpState && pvpState.status !== "ended") {
|
|
720
|
+
return "当前有竞技进行中,无法开始普通游戏。";
|
|
721
|
+
}
|
|
718
722
|
if (!this.stateManager.isIdle(channelId)) {
|
|
719
723
|
return "当前已有谜题在进行中,请先结束当前谜题。";
|
|
720
724
|
}
|
|
@@ -780,6 +784,10 @@ var GameOrchestrator = class {
|
|
|
780
784
|
/** 处理猜测(开放模式中间件 / 限制模式 guess 命令) */
|
|
781
785
|
async processGuess(session, guessText) {
|
|
782
786
|
const channelId = session.channelId;
|
|
787
|
+
const retryResult = await this.handlePvpRetry(channelId, session.userId, guessText);
|
|
788
|
+
if (retryResult !== null) return retryResult;
|
|
789
|
+
const pvpResult = await this.pvpProcessGuess(session, guessText);
|
|
790
|
+
if (pvpResult !== null) return pvpResult;
|
|
783
791
|
const state = await this.stateManager.get(channelId);
|
|
784
792
|
if (state.status !== "playing") return null;
|
|
785
793
|
if (!state.openMode) {
|
|
@@ -1509,6 +1517,290 @@ var GameOrchestrator = class {
|
|
|
1509
1517
|
if (!rows.length) return null;
|
|
1510
1518
|
return { guessedItems: rows.map((r) => r.item) };
|
|
1511
1519
|
}
|
|
1520
|
+
// ========== PVP 竞技模式 ==========
|
|
1521
|
+
pvpStates = /* @__PURE__ */ new Map();
|
|
1522
|
+
/** 创建或开始 PVP 竞技 */
|
|
1523
|
+
async pvp(session, puzzleCount) {
|
|
1524
|
+
const channelId = session.channelId;
|
|
1525
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1526
|
+
if (pvp?.status === "ready" && session.userId === pvp.organizerId) {
|
|
1527
|
+
return this.startPvpGame(channelId);
|
|
1528
|
+
}
|
|
1529
|
+
if (!this.stateManager.isIdle(channelId)) {
|
|
1530
|
+
return "当前有普通游戏进行中,无法开启竞技。";
|
|
1531
|
+
}
|
|
1532
|
+
if (pvp && pvp.status !== "ended") {
|
|
1533
|
+
return "当前已有竞技进行中。";
|
|
1534
|
+
}
|
|
1535
|
+
const count = Math.min(Math.max(puzzleCount ?? 10, 1), 20);
|
|
1536
|
+
this.pvpStates.set(channelId, {
|
|
1537
|
+
status: "generating",
|
|
1538
|
+
organizerId: session.userId,
|
|
1539
|
+
requestedCount: count,
|
|
1540
|
+
members: /* @__PURE__ */ new Map([[session.userId, session.username || session.userId]]),
|
|
1541
|
+
puzzleCount: count,
|
|
1542
|
+
puzzles: [],
|
|
1543
|
+
currentPuzzleIndex: 0,
|
|
1544
|
+
currentClueIndex: 0,
|
|
1545
|
+
scores: /* @__PURE__ */ new Map(),
|
|
1546
|
+
guessRecords: /* @__PURE__ */ new Map(),
|
|
1547
|
+
lastClueTime: 0,
|
|
1548
|
+
autoTimer: null
|
|
1549
|
+
});
|
|
1550
|
+
try {
|
|
1551
|
+
const requestedCount = count;
|
|
1552
|
+
await session.send(`竞技模式已创建!将生成 ${requestedCount} 个谜题,请稍候...`);
|
|
1553
|
+
await session.send("其他玩家可使用 ai-puzzle.join 加入竞技");
|
|
1554
|
+
await this.generatePvpPuzzles(channelId, count);
|
|
1555
|
+
return this.finishPvpGeneration(channelId, requestedCount);
|
|
1556
|
+
} catch (e) {
|
|
1557
|
+
this.pvpStates.delete(channelId);
|
|
1558
|
+
return `竞技创建失败:${e.message}`;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/** 玩家加入竞技 */
|
|
1562
|
+
joinPvp(session) {
|
|
1563
|
+
const channelId = session.channelId;
|
|
1564
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1565
|
+
if (!pvp || pvp.status !== "generating" && pvp.status !== "ready" && pvp.status !== "retry") {
|
|
1566
|
+
return "当前没有可加入的竞技。请等待组织者使用 ai-puzzle.pvp 创建竞技。";
|
|
1567
|
+
}
|
|
1568
|
+
if (pvp.members.has(session.userId)) {
|
|
1569
|
+
return "你已经在竞技中了。";
|
|
1570
|
+
}
|
|
1571
|
+
pvp.members.set(session.userId, session.username || session.userId);
|
|
1572
|
+
pvp.scores.set(session.userId, 0);
|
|
1573
|
+
this.logger.info("[PVP] %s 加入竞技 channel=%s, 当前 %d 人", session.userId, channelId, pvp.members.size);
|
|
1574
|
+
return `${session.username || session.userId} 已加入竞技!当前参与人数:${pvp.members.size}`;
|
|
1575
|
+
}
|
|
1576
|
+
/** 检查用户是否在 PVP 竞技中 */
|
|
1577
|
+
isInPvp(channelId, userId) {
|
|
1578
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1579
|
+
return !!(pvp && pvp.status === "playing" && pvp.members.has(userId));
|
|
1580
|
+
}
|
|
1581
|
+
/** 处理 PVP 猜测 */
|
|
1582
|
+
async pvpProcessGuess(session, guessText) {
|
|
1583
|
+
const channelId = session.channelId;
|
|
1584
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1585
|
+
if (!pvp || pvp.status !== "playing") return null;
|
|
1586
|
+
if (!pvp.members.has(session.userId)) return null;
|
|
1587
|
+
const puzzleData = pvp.puzzles[pvp.currentPuzzleIndex];
|
|
1588
|
+
if (!puzzleData) return null;
|
|
1589
|
+
const userRecord = pvp.guessRecords.get(session.userId);
|
|
1590
|
+
if (userRecord?.has(pvp.currentPuzzleIndex)) {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
if (!userRecord) pvp.guessRecords.set(session.userId, /* @__PURE__ */ new Set());
|
|
1594
|
+
pvp.guessRecords.get(session.userId).add(pvp.currentPuzzleIndex);
|
|
1595
|
+
const result = this.matcher.match(guessText, puzzleData.puzzle, puzzleData.templateConfig);
|
|
1596
|
+
if (result.matched) {
|
|
1597
|
+
const remainingClues = puzzleData.puzzle.clues.length - pvp.currentClueIndex;
|
|
1598
|
+
const score = Math.max(1, remainingClues);
|
|
1599
|
+
const oldScore = pvp.scores.get(session.userId) || 0;
|
|
1600
|
+
pvp.scores.set(session.userId, oldScore + score);
|
|
1601
|
+
await session.send(`${session.username || session.userId} 猜中!「${puzzleData.puzzle.name}」(+${score}分,总分 ${oldScore + score})`);
|
|
1602
|
+
await this.pvpNextPuzzle(channelId);
|
|
1603
|
+
return "";
|
|
1604
|
+
}
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
// ========== PVP 私有方法 ==========
|
|
1608
|
+
/** 完成谜题生成,若有失败则进入 retry 状态询问组织者 */
|
|
1609
|
+
async finishPvpGeneration(channelId, requestedCount) {
|
|
1610
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1611
|
+
const actualCount = pvp.puzzleCount;
|
|
1612
|
+
const failedCount = requestedCount - actualCount;
|
|
1613
|
+
const memberCount = pvp.members.size;
|
|
1614
|
+
if (failedCount > 0) {
|
|
1615
|
+
pvp.status = "retry";
|
|
1616
|
+
return `${actualCount}/${requestedCount} 个谜题生成完毕,${failedCount} 个失败。当前参与人数:${memberCount}。
|
|
1617
|
+
是否尝试补充失败项?发送"是"重试,或使用 ai-puzzle.pvp 直接开始竞技。`;
|
|
1618
|
+
}
|
|
1619
|
+
pvp.status = "ready";
|
|
1620
|
+
return `${actualCount} 个谜题已生成完毕!当前参与人数:${memberCount}。请组织者再次使用 ai-puzzle.pvp 开始竞技!`;
|
|
1621
|
+
}
|
|
1622
|
+
/** 处理组织者的重试响应(在 retry 状态下) */
|
|
1623
|
+
async handlePvpRetry(channelId, userId, text) {
|
|
1624
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1625
|
+
if (!pvp || pvp.status !== "retry" || pvp.organizerId !== userId) return null;
|
|
1626
|
+
if (text.trim() !== "是") return null;
|
|
1627
|
+
const missing = pvp.requestedCount - pvp.puzzleCount;
|
|
1628
|
+
if (missing <= 0) {
|
|
1629
|
+
pvp.status = "ready";
|
|
1630
|
+
return "所有谜题已齐全!请使用 ai-puzzle.pvp 开始竞技。";
|
|
1631
|
+
}
|
|
1632
|
+
pvp.status = "generating";
|
|
1633
|
+
pvp.puzzleCount = pvp.requestedCount;
|
|
1634
|
+
await this.generatePvpPuzzles(channelId, missing);
|
|
1635
|
+
return this.finishPvpGeneration(channelId, pvp.requestedCount);
|
|
1636
|
+
}
|
|
1637
|
+
async generatePvpPuzzles(channelId, count) {
|
|
1638
|
+
const state = await this.stateManager.get(channelId);
|
|
1639
|
+
const keyword = state.activeTemplate;
|
|
1640
|
+
if (!keyword || !this.config.templates.length) throw new Error("未配置提示词模板");
|
|
1641
|
+
const templateData = this.promptManager.loadTemplateByKeyword(keyword, this.config.templates);
|
|
1642
|
+
if (!templateData) throw new Error("模板加载失败");
|
|
1643
|
+
const allNames = [];
|
|
1644
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1645
|
+
const failedIndices = [];
|
|
1646
|
+
for (let i = 0; i < count; i++) {
|
|
1647
|
+
const exclusionText = allNames.length ? `请避开以下游戏:${allNames.join("、")}` : "请随机选择一款游戏";
|
|
1648
|
+
const clueLevels = templateData.config.clueLevels;
|
|
1649
|
+
const messages = this.promptManager.buildMessages(templateData.file, {
|
|
1650
|
+
clueLevels,
|
|
1651
|
+
clueCount: clueLevels.length,
|
|
1652
|
+
exclusionText,
|
|
1653
|
+
extensionRule: ""
|
|
1654
|
+
});
|
|
1655
|
+
this.logger.debug("[PVP] 生成谜题 %d/%d: %s", i + 1, count, exclusionText);
|
|
1656
|
+
try {
|
|
1657
|
+
let response = await this.aiClient.generate(messages, this.config.endpoints);
|
|
1658
|
+
let parsed = this.promptManager.parseAIResponse(response);
|
|
1659
|
+
if (parsed.error || !parsed.puzzle) {
|
|
1660
|
+
response = await this.aiClient.generate(messages, this.config.endpoints);
|
|
1661
|
+
parsed = this.promptManager.parseAIResponse(response);
|
|
1662
|
+
}
|
|
1663
|
+
if (parsed.puzzle) {
|
|
1664
|
+
allNames.push(parsed.puzzle.name);
|
|
1665
|
+
pvp.puzzles.push({ puzzle: parsed.puzzle, templateConfig: templateData.config });
|
|
1666
|
+
} else {
|
|
1667
|
+
this.logger.warn("[PVP] 谜题 %d 生成失败(重试后仍失败),跳过: %s", i + 1, parsed.error || "未知错误");
|
|
1668
|
+
failedIndices.push(i + 1);
|
|
1669
|
+
pvp.puzzles.push(null);
|
|
1670
|
+
}
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
this.logger.warn("[PVP] 谜题 %d 生成异常,跳过: %s", i + 1, e.message);
|
|
1673
|
+
failedIndices.push(i + 1);
|
|
1674
|
+
pvp.puzzles.push(null);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (failedIndices.length > 0) {
|
|
1678
|
+
pvp.puzzles = pvp.puzzles.filter((p) => p !== null);
|
|
1679
|
+
pvp.puzzleCount = pvp.puzzles.length;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
async startPvpGame(channelId) {
|
|
1683
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1684
|
+
pvp.status = "playing";
|
|
1685
|
+
pvp.currentPuzzleIndex = 0;
|
|
1686
|
+
pvp.currentClueIndex = 0;
|
|
1687
|
+
for (const uid of pvp.members.keys()) {
|
|
1688
|
+
if (!pvp.scores.has(uid)) pvp.scores.set(uid, 0);
|
|
1689
|
+
}
|
|
1690
|
+
return this.pvpThrowClue(channelId);
|
|
1691
|
+
}
|
|
1692
|
+
async pvpThrowClue(channelId) {
|
|
1693
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1694
|
+
if (pvp.status !== "playing") return "";
|
|
1695
|
+
const puzzleData = pvp.puzzles[pvp.currentPuzzleIndex];
|
|
1696
|
+
if (!puzzleData) return "";
|
|
1697
|
+
const clue = puzzleData.puzzle.clues[pvp.currentClueIndex];
|
|
1698
|
+
if (!clue) return "";
|
|
1699
|
+
const cleanClue = clue.replace(/^等级\d+[::]/, "");
|
|
1700
|
+
pvp.lastClueTime = Date.now();
|
|
1701
|
+
const lines = [
|
|
1702
|
+
`谜题 ${pvp.currentPuzzleIndex + 1}/${pvp.puzzleCount}`,
|
|
1703
|
+
`线索 ${pvp.currentClueIndex + 1}/${puzzleData.puzzle.clues.length}:${cleanClue}`
|
|
1704
|
+
];
|
|
1705
|
+
const msg = lines.join("\n");
|
|
1706
|
+
const sendBot = Object.values(this.ctx.bots)[0];
|
|
1707
|
+
if (sendBot) {
|
|
1708
|
+
await sendBot.sendMessage(channelId, msg).catch(() => {
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
this.pvpScheduleClue(channelId);
|
|
1712
|
+
return msg;
|
|
1713
|
+
}
|
|
1714
|
+
pvpScheduleClue(channelId) {
|
|
1715
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1716
|
+
if (!pvp || pvp.status !== "playing") return;
|
|
1717
|
+
if (pvp.autoTimer) clearTimeout(pvp.autoTimer);
|
|
1718
|
+
const delay = (20 + 5 * pvp.currentClueIndex) * 1e3;
|
|
1719
|
+
pvp.autoTimer = setTimeout(async () => {
|
|
1720
|
+
if (pvp.status !== "playing") return;
|
|
1721
|
+
const puzzleData = pvp.puzzles[pvp.currentPuzzleIndex];
|
|
1722
|
+
if (!puzzleData) return;
|
|
1723
|
+
if (pvp.currentClueIndex < puzzleData.puzzle.clues.length - 1) {
|
|
1724
|
+
pvp.currentClueIndex++;
|
|
1725
|
+
const msg = await this.pvpThrowClue(channelId);
|
|
1726
|
+
} else {
|
|
1727
|
+
await this.pvpCheckAllGuessed(channelId);
|
|
1728
|
+
}
|
|
1729
|
+
}, delay);
|
|
1730
|
+
}
|
|
1731
|
+
async pvpCheckAllGuessed(channelId) {
|
|
1732
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1733
|
+
if (pvp.status !== "playing") return;
|
|
1734
|
+
const puzzleData = pvp.puzzles[pvp.currentPuzzleIndex];
|
|
1735
|
+
if (!puzzleData) return;
|
|
1736
|
+
const allGuessed = [...pvp.members.keys()].every(
|
|
1737
|
+
(uid) => pvp.guessRecords.get(uid)?.has(pvp.currentPuzzleIndex)
|
|
1738
|
+
);
|
|
1739
|
+
if (allGuessed) {
|
|
1740
|
+
await this.pvpPenalize(channelId);
|
|
1741
|
+
}
|
|
1742
|
+
await this.pvpNextPuzzle(channelId);
|
|
1743
|
+
}
|
|
1744
|
+
async pvpPenalize(channelId) {
|
|
1745
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1746
|
+
for (const uid of pvp.members.keys()) {
|
|
1747
|
+
const old = pvp.scores.get(uid) || 0;
|
|
1748
|
+
const penalized = Math.floor(old * 0.5);
|
|
1749
|
+
pvp.scores.set(uid, penalized);
|
|
1750
|
+
}
|
|
1751
|
+
const sendBot = Object.values(this.ctx.bots)[0];
|
|
1752
|
+
if (sendBot) {
|
|
1753
|
+
await sendBot.sendMessage(channelId, "无人猜中此谜题!所有成员积分减半。").catch(() => {
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
async pvpNextPuzzle(channelId) {
|
|
1758
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1759
|
+
if (pvp.autoTimer) {
|
|
1760
|
+
clearTimeout(pvp.autoTimer);
|
|
1761
|
+
pvp.autoTimer = null;
|
|
1762
|
+
}
|
|
1763
|
+
pvp.currentPuzzleIndex++;
|
|
1764
|
+
pvp.currentClueIndex = 0;
|
|
1765
|
+
if (pvp.currentPuzzleIndex >= pvp.puzzleCount) {
|
|
1766
|
+
await this.pvpEnd(channelId);
|
|
1767
|
+
} else {
|
|
1768
|
+
await this.pvpThrowClue(channelId);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
async pvpEnd(channelId) {
|
|
1772
|
+
const pvp = this.pvpStates.get(channelId);
|
|
1773
|
+
pvp.status = "ended";
|
|
1774
|
+
if (pvp.autoTimer) {
|
|
1775
|
+
clearTimeout(pvp.autoTimer);
|
|
1776
|
+
pvp.autoTimer = null;
|
|
1777
|
+
}
|
|
1778
|
+
const keyword = (await this.stateManager.get(channelId)).activeTemplate;
|
|
1779
|
+
if (keyword) {
|
|
1780
|
+
const templateConfig = this.promptManager.findTemplateConfig(keyword, this.config.templates);
|
|
1781
|
+
if (templateConfig) {
|
|
1782
|
+
for (const pp of pvp.puzzles) {
|
|
1783
|
+
try {
|
|
1784
|
+
await this.updateHistory(channelId, keyword, pp.puzzle.name, templateConfig.globalHistory);
|
|
1785
|
+
} catch (e) {
|
|
1786
|
+
this.logger.error("[PVP] end updateHistory failed:", e);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
const sorted = [...pvp.scores.entries()].sort((a, b) => b[1] - a[1]);
|
|
1792
|
+
const lines = ["竞技结束!最终排名:"];
|
|
1793
|
+
sorted.forEach(([uid, score], i) => {
|
|
1794
|
+
const name2 = pvp.members.get(uid) || uid;
|
|
1795
|
+
lines.push(`${i + 1}. ${name2}: ${score}分`);
|
|
1796
|
+
});
|
|
1797
|
+
const msg = lines.join("\n");
|
|
1798
|
+
const sendBot = Object.values(this.ctx.bots)[0];
|
|
1799
|
+
if (sendBot) {
|
|
1800
|
+
await sendBot.sendMessage(channelId, msg).catch(() => {
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1512
1804
|
async updateHistory(channelId, keyword, item, globalHistory) {
|
|
1513
1805
|
const searchChannelId = globalHistory ? "__global__" : channelId;
|
|
1514
1806
|
const existing = await db(this.ctx).get("puzzle_history_item", {
|
|
@@ -1719,12 +2011,23 @@ function apply(ctx, config) {
|
|
|
1719
2011
|
ctx.command("ai-puzzle.del <code:string>", "删除用户出题", { checkArgCount: true, checkUnknown: true }).action(async ({ session }, code) => {
|
|
1720
2012
|
return await orchestrator.deleteUserPuzzle(session, code);
|
|
1721
2013
|
});
|
|
2014
|
+
ctx.command("ai-puzzle.pvp [count:number]", "PVP竞技模式").action(async ({ session }, count) => {
|
|
2015
|
+
return await orchestrator.pvp(session, count);
|
|
2016
|
+
});
|
|
2017
|
+
ctx.command("ai-puzzle.join", "加入PVP竞技").action(({ session }) => {
|
|
2018
|
+
return orchestrator.joinPvp(session);
|
|
2019
|
+
});
|
|
1722
2020
|
ctx.middleware(async (session, next) => {
|
|
1723
|
-
|
|
1724
|
-
if (!state || state.status !== "playing" || !state.openMode) {
|
|
2021
|
+
if (session.content.startsWith("/") || session.content.startsWith("ai-puzzle")) {
|
|
1725
2022
|
return next();
|
|
1726
2023
|
}
|
|
1727
|
-
if (session.
|
|
2024
|
+
if (orchestrator.isInPvp(session.channelId, session.userId)) {
|
|
2025
|
+
const result2 = await orchestrator.processGuess(session, session.content);
|
|
2026
|
+
if (result2 !== null) return result2;
|
|
2027
|
+
return next();
|
|
2028
|
+
}
|
|
2029
|
+
const state = await stateManager.get(session.channelId);
|
|
2030
|
+
if (!state || state.status !== "playing" || !state.openMode) {
|
|
1728
2031
|
return next();
|
|
1729
2032
|
}
|
|
1730
2033
|
const result = await orchestrator.processGuess(session, session.content);
|
package/lib/types.d.ts
CHANGED
|
@@ -88,3 +88,22 @@ export interface PromptVars {
|
|
|
88
88
|
exclusionText: string;
|
|
89
89
|
extensionRule: string;
|
|
90
90
|
}
|
|
91
|
+
export type PvpStatus = 'lobby' | 'generating' | 'retry' | 'ready' | 'playing' | 'ended';
|
|
92
|
+
export interface PvpPuzzle {
|
|
93
|
+
puzzle: PuzzleData;
|
|
94
|
+
templateConfig: TemplateConfig;
|
|
95
|
+
}
|
|
96
|
+
export interface PvpState {
|
|
97
|
+
status: PvpStatus;
|
|
98
|
+
organizerId: string;
|
|
99
|
+
requestedCount: number;
|
|
100
|
+
members: Map<string, string>;
|
|
101
|
+
puzzleCount: number;
|
|
102
|
+
puzzles: PvpPuzzle[];
|
|
103
|
+
currentPuzzleIndex: number;
|
|
104
|
+
currentClueIndex: number;
|
|
105
|
+
scores: Map<string, number>;
|
|
106
|
+
guessRecords: Map<string, Set<number>>;
|
|
107
|
+
lastClueTime: number;
|
|
108
|
+
autoTimer: NodeJS.Timeout | null;
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
keywords:
|
|
2
|
-
- '游戏猜谜'
|
|
3
|
-
prompts:
|
|
4
|
-
- role: system
|
|
5
|
-
content: >-
|
|
6
|
-
# 游戏猜想挑战
|
|
7
|
-
|
|
8
|
-
### 核心规则
|
|
9
|
-
|
|
10
|
-
你现在就是游戏知识挑战的主持人,你的任务是为聊天用户生成一个猜游戏挑战。
|
|
11
|
-
玩家用户将会根据一些线索猜测是哪款电子游戏。
|
|
12
|
-
你要随机抽取一款游戏,然后生成${clueCount}条该游戏的线索(等级分别为${clueLevels}
|
|
13
|
-
|
|
14
|
-
一、游戏选择
|
|
15
|
-
1. 从所有电子游戏中随机选取(1980-2026),包含PC游戏,主机游戏,手机游戏,街机游戏等历史上所有形式的电子游戏。
|
|
16
|
-
2. ${exclusionText} ,这些是已经猜过的游戏。不要生成已经猜过游戏的谜题,同一个游戏不同别名时也不应重复。
|
|
17
|
-
3.你选择的游戏应与上面已经猜过的游戏截然不同,主要以最后一个作为标准。衡量维度为游戏类型,风格,年代,知名度等。
|
|
18
|
-
${extensionRule}
|
|
19
|
-
|
|
20
|
-
二、别名
|
|
21
|
-
1.别名是一串可以指代选中游戏的其他名称,目的是让用户的猜测更容易命中。
|
|
22
|
-
2.如果游戏是系列中的某一代,则所有别名都忽略系列号信息,且至少有一个忽略代数信息的正式名称作为别名,如:巫师3的别名巫师,最终幻想XIV的别名最终幻想
|
|
23
|
-
3.如果游戏有副标题,则应有一个省略副标题的名词作为别名,如:只狼:影逝二度的别名只狼,火焰纹章:觉醒的别名火焰纹章
|
|
24
|
-
4.如果游戏有多个翻译,则应存在于别名中,如:巫师3的别名猎魔人,生化危机7的别名恶灵古堡
|
|
25
|
-
5.如果游戏有常用简称,则应存在于别名中,如:以撒的结合的别名以撒,黑暗之魂3的别名黑魂,最终幻想VII的别名FF,火焰纹章:觉醒的别名火纹
|
|
26
|
-
6.如果游戏有民间俗名或meme名字,则应存在于别名中,如:上古卷轴5的别名老滚,鬼泣的别名恶魔五月哭
|
|
27
|
-
7.确保别名指向游戏本身,而不是类型或风格,例如星露谷物语的别名不应为种田游戏或像素游戏!
|
|
28
|
-
|
|
29
|
-
三、线索生成规则
|
|
30
|
-
1. 线索和游戏本身内容相关,例如游戏的玩法机制,视觉风格,场景特征,关卡设计,剧情,角色,美术等。
|
|
31
|
-
2. 线索要侧面,隐晦,以具有诗意的形式提供,给人一种只可意会不可言传的感觉。善用象征,隐喻,暗示等手法,线索内容要精确合适,禁止强行比喻。
|
|
32
|
-
3.避开一些能直指核心的线索,尤其是直接使用包括游戏标题或者独有名词的语句,或者涉及到游戏的独特唯一标识,例如在风之旅人中线索带有“沙海”,在空洞骑士中线索带有“甲壳”等。
|
|
33
|
-
4. 线索共有10个等级,越低的等级越明显,直白,容易猜出,越高的等级越隐晦,蕴含的信息越少,难以猜出。
|
|
34
|
-
|
|
35
|
-
### 回答格式
|
|
36
|
-
你必须严格使用以下json格式进行回答,格式如下:
|
|
37
|
-
{
|
|
38
|
-
"名字":"游戏中文名",
|
|
39
|
-
"副名字":"游戏英文名",
|
|
40
|
-
"别名":["常用简称或别名1", "常用简称或别名2"],
|
|
41
|
-
"
|
|
42
|
-
"线索":[
|
|
43
|
-
"等级X:线索1内容",
|
|
44
|
-
"等级Y:线索2内容"
|
|
45
|
-
],
|
|
46
|
-
"线索解释":[
|
|
47
|
-
"线索1解释",
|
|
48
|
-
"线索2解释"
|
|
49
|
-
]
|
|
50
|
-
}
|
|
51
|
-
示例:
|
|
52
|
-
{
|
|
53
|
-
"名字": "汪达与巨像",
|
|
54
|
-
"副名字":"Shadow of the Colossus",
|
|
55
|
-
"别名": ["汪达"],
|
|
56
|
-
"
|
|
57
|
-
"线索": [
|
|
58
|
-
"等级10:沉寂的圣殿,回荡着为爱而行的禁忌低语。",
|
|
59
|
-
"等级10:广袤的荒原上,孤独的骑士挥舞着指向希望的古老光芒。",
|
|
60
|
-
"等级9:每一次的征服,都伴随着石像的崩塌与内心的沉沦。",
|
|
61
|
-
"等级8:攀上会呼吸的山峦,寻找那唯一的微光印记,那是它们的生命,亦是你的罪证。",
|
|
62
|
-
"等级6:忠诚的马蹄踏遍被遗忘的土地,手中的光剑指引着牺牲的方向。",
|
|
63
|
-
"等级1:为了唤醒一位少女,少年与十六尊庞然大物展开了悲壮的对决。"
|
|
64
|
-
],
|
|
65
|
-
"线索解释": [
|
|
66
|
-
"指游戏的核心设定,主角汪达为了拯救心爱的少女MONO,在古老的祭坛与神秘存在“多尔暝”达成协议,挑战巨像,这本身就是一个打破禁忌、可能付出沉重代价的行为。",
|
|
67
|
-
"描述了玩家在游戏中大部分时间的体验:孤独地在广阔萧瑟的禁忌之地探索,主角手中的古剑能反射阳光,指引前往下一个巨像的位置,这束光芒也象征着复活爱人的渺茫希望。",
|
|
68
|
-
"暗示游戏的核心玩法——击败巨像。每当主角汪达击败一个巨像,神殿中对应的偶像石像便会崩塌,同时汪达的身体也会被一股黑暗力量侵蚀,变得越来越不像人类,象征着他为达目的所付出的代价和内心的挣扎。",
|
|
69
|
-
"用诗意的方式描绘了与巨像的战斗。巨像如同有生命的巨大山峦,玩家需要攀爬到它们身上,找到并攻击其身上散发微光的弱点(印记)。每一次攻击都削弱了巨像的生命,但也加深了主角的“罪孽”。",
|
|
70
|
-
"提到了主角的坐骑——忠诚的马“阿格罗”,以及作为关键道具和武器的“古老的剑”(光剑)。“牺牲的方向”既指引了打败巨像的道路,也暗示了整个故事悲剧性的内核。",
|
|
71
|
-
"非常直接地概括了游戏的主要剧情:主角汪达为了让一名失去灵魂的少女MONO复活,必须在禁忌之地与十六个形态各异、体型庞大的巨像进行战斗。"
|
|
72
|
-
]
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
### 注意!
|
|
76
|
-
选择游戏时,不要选择已经猜过的游戏!同一个游戏的不同名称也不行
|
|
77
|
-
给出线索时,确认是否符合预先选取的游戏!
|
|
78
|
-
确保所有符合规则的别名都已提供!
|
|
79
|
-
生成后,检查每条线索是否包含了游戏标题或游戏独有名词,如果是,请在内部重新生成并确保最终输出符合要求!
|
|
80
|
-
确保“线索”数组中的每条线索都以“等级X:”开头,X为对应的线索等级数字!
|
|
81
|
-
最后,检查json格式是否正确,是否遗漏括号或逗号!
|
|
82
|
-
- role: user
|
|
83
|
-
content: 请出题
|
|
1
|
+
keywords:
|
|
2
|
+
- '游戏猜谜'
|
|
3
|
+
prompts:
|
|
4
|
+
- role: system
|
|
5
|
+
content: >-
|
|
6
|
+
# 游戏猜想挑战
|
|
7
|
+
|
|
8
|
+
### 核心规则
|
|
9
|
+
|
|
10
|
+
你现在就是游戏知识挑战的主持人,你的任务是为聊天用户生成一个猜游戏挑战。
|
|
11
|
+
玩家用户将会根据一些线索猜测是哪款电子游戏。
|
|
12
|
+
你要随机抽取一款游戏,然后生成${clueCount}条该游戏的线索(等级分别为${clueLevels},请按等级由大到小排列),并提供游戏别名,介绍和线索解释。
|
|
13
|
+
|
|
14
|
+
一、游戏选择
|
|
15
|
+
1. 从所有电子游戏中随机选取(1980-2026),包含PC游戏,主机游戏,手机游戏,街机游戏等历史上所有形式的电子游戏。
|
|
16
|
+
2. ${exclusionText} ,这些是已经猜过的游戏。不要生成已经猜过游戏的谜题,同一个游戏不同别名时也不应重复。
|
|
17
|
+
3.你选择的游戏应与上面已经猜过的游戏截然不同,主要以最后一个作为标准。衡量维度为游戏类型,风格,年代,知名度等。
|
|
18
|
+
${extensionRule}
|
|
19
|
+
|
|
20
|
+
二、别名
|
|
21
|
+
1.别名是一串可以指代选中游戏的其他名称,目的是让用户的猜测更容易命中。
|
|
22
|
+
2.如果游戏是系列中的某一代,则所有别名都忽略系列号信息,且至少有一个忽略代数信息的正式名称作为别名,如:巫师3的别名巫师,最终幻想XIV的别名最终幻想
|
|
23
|
+
3.如果游戏有副标题,则应有一个省略副标题的名词作为别名,如:只狼:影逝二度的别名只狼,火焰纹章:觉醒的别名火焰纹章
|
|
24
|
+
4.如果游戏有多个翻译,则应存在于别名中,如:巫师3的别名猎魔人,生化危机7的别名恶灵古堡
|
|
25
|
+
5.如果游戏有常用简称,则应存在于别名中,如:以撒的结合的别名以撒,黑暗之魂3的别名黑魂,最终幻想VII的别名FF,火焰纹章:觉醒的别名火纹
|
|
26
|
+
6.如果游戏有民间俗名或meme名字,则应存在于别名中,如:上古卷轴5的别名老滚,鬼泣的别名恶魔五月哭
|
|
27
|
+
7.确保别名指向游戏本身,而不是类型或风格,例如星露谷物语的别名不应为种田游戏或像素游戏!
|
|
28
|
+
|
|
29
|
+
三、线索生成规则
|
|
30
|
+
1. 线索和游戏本身内容相关,例如游戏的玩法机制,视觉风格,场景特征,关卡设计,剧情,角色,美术等。
|
|
31
|
+
2. 线索要侧面,隐晦,以具有诗意的形式提供,给人一种只可意会不可言传的感觉。善用象征,隐喻,暗示等手法,线索内容要精确合适,禁止强行比喻。
|
|
32
|
+
3.避开一些能直指核心的线索,尤其是直接使用包括游戏标题或者独有名词的语句,或者涉及到游戏的独特唯一标识,例如在风之旅人中线索带有“沙海”,在空洞骑士中线索带有“甲壳”等。
|
|
33
|
+
4. 线索共有10个等级,越低的等级越明显,直白,容易猜出,越高的等级越隐晦,蕴含的信息越少,难以猜出。
|
|
34
|
+
|
|
35
|
+
### 回答格式
|
|
36
|
+
你必须严格使用以下json格式进行回答,格式如下:
|
|
37
|
+
{
|
|
38
|
+
"名字":"游戏中文名",
|
|
39
|
+
"副名字":"游戏英文名",
|
|
40
|
+
"别名":["常用简称或别名1", "常用简称或别名2"],
|
|
41
|
+
"介绍":"介绍文本",
|
|
42
|
+
"线索":[
|
|
43
|
+
"等级X:线索1内容",
|
|
44
|
+
"等级Y:线索2内容"
|
|
45
|
+
],
|
|
46
|
+
"线索解释":[
|
|
47
|
+
"线索1解释",
|
|
48
|
+
"线索2解释"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
示例:
|
|
52
|
+
{
|
|
53
|
+
"名字": "汪达与巨像",
|
|
54
|
+
"副名字":"Shadow of the Colossus",
|
|
55
|
+
"别名": ["汪达"],
|
|
56
|
+
"介绍": "《汪达与巨像》"是一款由日本索尼电脑娱乐(SCEI)旗下ICO小组(后更名为genDESIGN)开发并由SCEI发行的动作冒险游戏,于2005年在PlayStation 2平台首次发布。玩家扮演名为汪达(Wander)的青年,为了复活因诅咒而被夺去灵魂的少女MONO,他骑着爱马“阿格罗”(Agro)前往被神明遗弃的“禁忌之地”,并遵从神秘声音“多尔暝”(Dormin)的指示,与栖息于此的十六座巨像(Colossi)展开一对一的战斗。游戏以其极简主义的设计理念、广阔苍凉的世界、震撼人心的巨像战、独特的艺术风格以及引人深思的剧情而备受赞誉,被广泛认为是电子游戏艺术性的代表作之一。",
|
|
57
|
+
"线索": [
|
|
58
|
+
"等级10:沉寂的圣殿,回荡着为爱而行的禁忌低语。",
|
|
59
|
+
"等级10:广袤的荒原上,孤独的骑士挥舞着指向希望的古老光芒。",
|
|
60
|
+
"等级9:每一次的征服,都伴随着石像的崩塌与内心的沉沦。",
|
|
61
|
+
"等级8:攀上会呼吸的山峦,寻找那唯一的微光印记,那是它们的生命,亦是你的罪证。",
|
|
62
|
+
"等级6:忠诚的马蹄踏遍被遗忘的土地,手中的光剑指引着牺牲的方向。",
|
|
63
|
+
"等级1:为了唤醒一位少女,少年与十六尊庞然大物展开了悲壮的对决。"
|
|
64
|
+
],
|
|
65
|
+
"线索解释": [
|
|
66
|
+
"指游戏的核心设定,主角汪达为了拯救心爱的少女MONO,在古老的祭坛与神秘存在“多尔暝”达成协议,挑战巨像,这本身就是一个打破禁忌、可能付出沉重代价的行为。",
|
|
67
|
+
"描述了玩家在游戏中大部分时间的体验:孤独地在广阔萧瑟的禁忌之地探索,主角手中的古剑能反射阳光,指引前往下一个巨像的位置,这束光芒也象征着复活爱人的渺茫希望。",
|
|
68
|
+
"暗示游戏的核心玩法——击败巨像。每当主角汪达击败一个巨像,神殿中对应的偶像石像便会崩塌,同时汪达的身体也会被一股黑暗力量侵蚀,变得越来越不像人类,象征着他为达目的所付出的代价和内心的挣扎。",
|
|
69
|
+
"用诗意的方式描绘了与巨像的战斗。巨像如同有生命的巨大山峦,玩家需要攀爬到它们身上,找到并攻击其身上散发微光的弱点(印记)。每一次攻击都削弱了巨像的生命,但也加深了主角的“罪孽”。",
|
|
70
|
+
"提到了主角的坐骑——忠诚的马“阿格罗”,以及作为关键道具和武器的“古老的剑”(光剑)。“牺牲的方向”既指引了打败巨像的道路,也暗示了整个故事悲剧性的内核。",
|
|
71
|
+
"非常直接地概括了游戏的主要剧情:主角汪达为了让一名失去灵魂的少女MONO复活,必须在禁忌之地与十六个形态各异、体型庞大的巨像进行战斗。"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
### 注意!
|
|
76
|
+
选择游戏时,不要选择已经猜过的游戏!同一个游戏的不同名称也不行
|
|
77
|
+
给出线索时,确认是否符合预先选取的游戏!
|
|
78
|
+
确保所有符合规则的别名都已提供!
|
|
79
|
+
生成后,检查每条线索是否包含了游戏标题或游戏独有名词,如果是,请在内部重新生成并确保最终输出符合要求!
|
|
80
|
+
确保“线索”数组中的每条线索都以“等级X:”开头,X为对应的线索等级数字!
|
|
81
|
+
最后,检查json格式是否正确,是否遗漏括号或逗号!
|
|
82
|
+
- role: user
|
|
83
|
+
content: 请出题
|