Skip to content

二次开发指南

本文档将介绍如何对 CCXC Engine 的后端服务 ccxc-backend 进行二次开发和定制。

何时需要二次开发?

作为 Puzzle Hunt 很难避免二次开发。例如你可能需要:

  • 实现定制的进度推进逻辑
  • 添加特殊的题目交互
  • 实现特殊的分区、题目、剧情的展示
  • 更换已有的模块(如邮件系统) 等

关于二次开发

CCXC Engine 框架提供了完整的基础设施,然而 Puzzle Hunt 的特殊性使得需要开发独特的机制几乎是必然的选择。因此我们在开发框架时就考虑如何方便二次开发。

当前的版本需要修改的文件还比较分散,今后我们会继续整合,将自定义逻辑进一步抽出。

开发环境准备

在开始二次开发之前,请确保你已经:

  1. 完成了开发环境搭建
  2. 熟悉 C# 和 .NET 开发
  3. 了解 Puzzle Hunt 的基本概念和流程
  4. 准备好你的创意想法!

代码结构

让我们先来了解一下 ccxc-backend 的整体架构:

.
├─ccxc-backend
│  ├─Config                     # 配置文件和配置加载器
│  ├─Controllers                # HTTP API 控制器
│  │  ├─Admin                   # 管理后台相关接口
│  │  ├─Announcements           # 公告相关接口
│  │  ├─Game                    # 比赛核心逻辑接口
│  │  ├─Groups                  # 队伍管理接口
│  │  ├─Invites                 # 邀请系统接口
│  │  ├─Mail                    # 邮件服务接口
│  │  ├─System                  # 系统全局接口
│  │  └─Users                   # 用户管理接口
│  ├─DataModels                 # 数据库实体模型
│  ├─DataServices               # 数据访问层服务
│  └─Functions                  # 业务逻辑辅助函数
├─Ccxc.Core.DbOrm               # 底层库:数据库 ORM 封装
├─Ccxc.Core.HttpServer          # 底层库:HTTP 服务器框架
├─Ccxc.Core.Plugins             # 底层库:插件开发工具包
└─Ccxc.Core.Utils               # 底层库:通用工具集

可以很方便的找到某个功能应该在哪里:

  • Controllers 层处理 HTTP 请求和响应
  • DataModels 层定义数据结构
  • DataServices 层封装数据库操作
  • Functions 层包含可复用的业务逻辑

存档

SaveData 结构

CCXC Engine 使用一个名为 SaveData 的核心数据结构来追踪每支队伍的进度状态。这个类位于 ccxc-backend/DataModels/progress.cs

csharp
public class SaveData
{
    /// <summary>
    /// 已完成的题目(pid)
    /// </summary>
    public HashSet<int> FinishedProblems { get; set; } = new HashSet<int>();

    /// <summary>
    /// 已解锁的小题(pid)
    /// </summary>
    public HashSet<int> UnlockedProblems { get; set; } = new HashSet<int>();

    /// <summary>
    /// 已完成的分区(pgid)
    /// </summary>
    public HashSet<int> FinishedGroups { get; set; } = new HashSet<int>();

    /// <summary>
    /// 已解锁的分区(pgid)
    /// </summary>
    public HashSet<int> UnlockedGroups { get; set; } = new HashSet<int>();

    /// <summary>
    /// 题目解锁时间(pid -> 解锁时间)
    /// </summary>
    public Dictionary<int, DateTime> ProblemUnlockTime { get; set; } = new Dictionary<int, DateTime>();

    /// <summary>
    /// 各题目的答案提交次数(pid -> 次数)
    /// </summary>
    public Dictionary<int, int> ProblemAnswerSubmissionsCount { get; set; } = new Dictionary<int, int>();

    /// <summary>
    /// 已购买的额外题目回答次数(pid -> 次数)
    /// </summary>
    public Dictionary<int, int> AdditionalProblemAttemptsCount { get; set; } = new Dictionary<int, int>();

    /// <summary>
    /// 已兑换过的提示(pid -> (提示id))
    /// </summary>
    public Dictionary<int, HashSet<int>> OpenedHints { get; set; } = new Dictionary<int, HashSet<int>>();

    /// <summary>
    /// 题目当前状态(pid -> (状态名 -> 状态值))
    /// </summary>
    public Dictionary<int, Dictionary<string, string>> ProblemStatus { get; set; } = new Dictionary<int, Dictionary<string, string>>();
}

存档设计思路

存档系统采用了"一切皆状态"的设计理念。ProblemStatus 字典允许你为每道题目存储任意的键值对状态,这为复杂的题目交互提供了无限可能。

数值配置中心

CCXC Engine 提供了一个基于 Redis 的数值配置中心,将一些需要快速调整和配置的参数存储其中。

所有的配置项都定义在 ccxc-backend/Functions/RedisNumberCenter.cs 中:

以“初始信用点” InitialPowerPoint 为例

csharp
/// <summary>
/// 初始信用点
/// </summary>
public static int InitialPowerPoint
{
    get
    {
        return GetInt("initial_power_point").GetAwaiter().GetResult();
    }
    set
    {
        SetInt("initial_power_point", value).GetAwaiter().GetResult();
    }
}

常用配置项说明

配置项作用默认值建议
InitialPowerPoint初始信用点数量100
PowerIncreaseRate信用点增长速率(每分钟)10
InitialGroupCount开局解锁的分区数量1
UnlockTipFunctionAfter提示功能解锁延迟(分钟)30

添加自定义数值

如果你需要添加新的数值配置项,按照以下步骤:

  1. RedisNumberCenter.cs 中添加属性定义
  2. ccxc-backend/Controllers/Admin/DynamicNumericalController.cs 对应的管理 API 中添加这个配置项的读写逻辑。
  3. 在管理后台界面中添加配置项显示
  4. 在你需要的地方使用 RedisNumberCenter.<配置项名称> 引用它。

注意

修改配置项时,记得同步更新管理后台的读写 API,否则管理员将无法在后台修改这些数值。

解锁流程定制

查看文件 ccxc-backend/Controllers/Game/GameProgressExtend.cs 。这个文件包含许多解锁流程相关的内容,这是二次开发经常会修改的部分。

初始存档生成

每当新队伍首次进入游戏时,系统会调用 NewSaveData 函数生成初始存档。

在这个函数中:

  1. 我们首先为当前用户初始化了一份新的 SaveData 对象。
  2. 然后查看数值中心的初始分区数量。
  3. 对于每个初始分区,我们都查看了数值中心中关于此分区初始解锁的题目数量。
  4. 对于每个初始分区,我们按照PID顺序找到对应题目数量的小题,标记它们解锁。

这一逻辑可以适用于大多数情况,但是有时你仍然需要定制它。

Meta 和 Final Meta 配置

为了开发方便,我们没有通过读取数据库或者配置文件的方式,而是直接在源码中指定了 MetaMeta 和 Final meta 的 PID 。

这些条件在判题函数中推进进度时会被引用。

csharp
// 返回 Meta Meta 的 PID
public static int GetMMPid => 58;

// 返回 Final Meta 的 PID  
public static int GetFMPid => 59;

以及 IsFinishedFinalMetaIsFMOpen 函数。

在编写自己的 Puzzle Hunt 时,你几乎必须修改它们。

剧情查看

GetOpenPuzzleArticleId 函数中返回了当前允许查看哪些“题目文章”,而 CanReadPuzzleArticle 函数则是判断给出的题目文章的 Key 现在是否可查看。

默认的逻辑是:

  • g1-prologue 没有任何限制
  • g${gpid}-prologuegpid 分区解锁时可见。
  • g${pgid}-endgpid 分区完成时(解出分区 Meta 时)可见。
  • main-open 在第 1 区完成时可见。
  • finalend 在 Final meta 完成时可见。

题目解锁

UnlockGroup UnlockNextPuzzleUnlockSinglePuzzle 中实现了解锁逻辑。

  • 在需要解锁分区时,会调用 UnlockGroup 函数。在向 SaveData.UnlockedGroups 标记分区解锁的同时,也会一并解锁该分区的初始小题。

  • 在推进下一小题的解锁进度时,会调用 UnlockNextPuzzle 函数。该函数会找出当前分区按 PID 顺序的下一小题,并调用 UnlockSinglePuzzle 解锁。

  • 当需要解锁指定的小题时,会调用 UnlockSinglePuzzle 函数。

你可以修改这些函数实现特定的解锁逻辑。

判题系统

判题流程的代码实现在 ccxc-backend/Controllers/Game/OperateController.cs 中。这里有两个函数:

  • CheckAnswer API
  • 进度推进

判题流程概览

进度推进定制

进度推进的核心逻辑在 PushNextHelper 函数中。这是你最可能需要修改的地方:

csharp
public static async Task<(int code, int answerStatus, int extendFlag, string message, string location)> PushNextHelper(
    progress progress, Progress progressDb,
    puzzle puzzleItem, List<puzzle> puzzleList, answer_log answerLog, AnswerLog answerLogDb,
    int uid, int gid, string username,
    DateTime now, string extraMessage
    )
{
    // 在判断回答正确后所有的逻辑都要放在这里
}

题目展示逻辑

题目展示的核心逻辑在 ccxc-backend/Controllers/Game/GameController.cs 中。这里包含了几个关键的 API:

主页信息 API

GetMainInfo 方法决定了玩家在主页看到的内容:

csharp
[HttpHandler("POST", "/play/get-main-info")]
public async Task GetMainInfo(Request request, Response response)
{
    // 根据用户进度生成导航栏和可见区域
    var navBar = new List<NavbarItem>
    {
        new NavbarItem
        {
            title = Config.SystemConfigLoader.Config.ProjectName,
            path = "/main",
        }
    };
    
    // 根据进度添加更多导航项
    if (progressData.IsFinishedFinalMeta())
    {
        navBar.Add(new NavbarItem
        {
            title = "尾声",
            path = "/article/finalend"
        });
    }
}

分区信息 API

GetPuzzleInfo 方法控制每个分区内显示哪些题目:

csharp
// 将分区已解锁的题目插入题目列表
var puzzleList = new List<DetailBasicInfo>();
foreach (var puzzle in puzzleBasicList)
{
    var isUnlocked = progressData.UnlockedProblems.Contains(puzzle.pid);
    var isPgid3Meta = (pgid == 3 && puzzle.answer_type == 1); // 特殊逻辑示例
    
    if (isUnlocked && !isPgid3Meta)
    {
        puzzleList.Add(new DetailBasicInfo
        {
            pid = puzzle.pid,
            title = puzzle.title,
            puzzle_type = puzzle.answer_type,
            is_finished = progressData.FinishedProblems.Contains(puzzle.pid) ? 1 : 0,
            answer = GetShowedAnswer(puzzle.pid, puzzle.answer, progressData),
            is_unlocked = isUnlocked ? 1 : 0,
            extend_data = puzzle.extend_data
        });
    }
}

动态答案显示

你可以实现动态的答案显示逻辑。比如某些题目解答后不显示真实答案,而是显示特殊文本:

csharp
string GetShowedAnswer(int pid, string answer, SaveData progressData)
{
    if (!progressData.FinishedProblems.Contains(pid))
    {
        return null;
    }

    if (progressData.ProblemStatus.ContainsKey(pid))
    {
        if (progressData.ProblemStatus[pid].ContainsKey("__$$ShowAnswer"))
        {
            return progressData.ProblemStatus[pid]["__$$ShowAnswer"];
        }
    }
    return answer;
}

扩展功能开发

邮件系统定制

邮件发送功能在 ccxc-backend/Functions/EmailSender.cs 中实现。默认使用阿里云邮件服务,你可以根据需要替换为其他服务商:

csharp
// 示例:集成 SendGrid
public static async Task SendActivationEmailViaSendGrid(string toEmail, string activationLink)
{
    var client = new SendGridClient("your-api-key");
    var from = new EmailAddress("noreply@yoursite.com", "Your Puzzle Hunt");
    var to = new EmailAddress(toEmail);
    var msg = MailHelper.CreateSingleEmail(from, to, "激活你的账号", 
        $"请点击链接激活:{activationLink}");
    
    await client.SendEmailAsync(msg);
}

SSO 安全配置

如果你使用单点登录功能,需要在 ccxc-backend/Controllers/Users/SsoController.cs 中配置可信域名:

csharp
if (host.EndsWith("yoursite.com", StringComparison.OrdinalIgnoreCase) ||
    host.EndsWith("yourdomain.net", StringComparison.OrdinalIgnoreCase))
{
    await response.OK();
    return;
}

二次开发建议

在二次开发的过程中请经常进行测试。思考用户进行何种操作之后会到达这里的代码,并通过调试器观察代码执行是否符合预期。

调试

  1. 使用断点调试:在关键的判题和解锁逻辑处设置断点
  2. 日志记录:在关键步骤添加详细的日志输出
  3. 存档检查:定期检查 SaveData 的状态是否符合预期

保持兼容

在修改原有逻辑,特别是 SaveData 时,需要注意与原有逻辑的兼容。

🚀

需要帮助?

如果在二次开发过程中遇到问题,欢迎查看项目的 GitHub 仓库,或者联系开发团队获取支持。

Released under the MIT License. Powered by VitePress.