resolveCtsStc(Class> commandClass) {
+ Class> user = ClassUtils.getUserClass(commandClass);
+ ParameterizedType pt = findAbstractTypedSuper(user);
+ if (pt == null) {
+ // 非 AbstractTypedPbCommand 子类,或泛型在运行期被擦除到无法解析
+ return java.util.Optional.empty();
+ }
+ Type[] args = pt.getActualTypeArguments();
+ if (args.length < 2) {
+ return java.util.Optional.empty();
+ }
+ Class> ctsClass = rawClass(args[0]);
+ Class> builderOrSecond = rawClass(args[1]);
+ if (ctsClass == null
+ || builderOrSecond == null
+ || !Message.class.isAssignableFrom(ctsClass)) {
+ return java.util.Optional.empty();
+ }
+ // Java PB 惯例:Message.Builder 为 Message 的静态内部类,外层即 STC 类型
+ Class> stcClass = builderOrSecond.getDeclaringClass();
+ if (stcClass == null || !Message.class.isAssignableFrom(stcClass)) {
+ return java.util.Optional.empty();
+ }
+ return java.util.Optional.of(
+ new CtsStc(ctsClass.asSubclass(Message.class), stcClass.asSubclass(Message.class)));
+ }
+
+ private static ParameterizedType findAbstractTypedSuper(Class> c) {
+ while (c != null && c != Object.class) {
+ Type t = c.getGenericSuperclass();
+ if (t instanceof ParameterizedType p && p.getRawType() == AbstractTypedPbCommand.class) {
+ return p;
+ }
+ c = c.getSuperclass();
+ }
+ return null;
+ }
+
+ private static Class> rawClass(Type t) {
+ if (t instanceof Class> cl) {
+ return cl;
+ }
+ if (t instanceof ParameterizedType p && p.getRawType() instanceof Class> cl) {
+ return cl;
+ }
+ return null;
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/frame/LengthPrefixedProtobuf.java b/BaseModule/src/main/java/com/huangzj/base/frame/LengthPrefixedProtobuf.java
new file mode 100644
index 0000000..c045d94
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/frame/LengthPrefixedProtobuf.java
@@ -0,0 +1,32 @@
+package com.huangzj.base.frame;
+
+import com.google.protobuf.MessageLite;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufAllocator;
+import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
+
+/**
+ * TCP 上 4 字节长度 + protobuf body 的通用写法,跨服和区服客户端共用。
+ */
+public final class LengthPrefixedProtobuf {
+
+ /** 单帧上限,防止恶意大包把内存打爆。 */
+ public static final int MAX_FRAME_LENGTH = 65536;
+
+ private LengthPrefixedProtobuf() {
+ }
+
+ /** 长度字段在开头 4 字节,剥离长度头后剩下纯 PB。 */
+ public static LengthFieldBasedFrameDecoder newTcpFrameDecoder() {
+ return new LengthFieldBasedFrameDecoder(MAX_FRAME_LENGTH, 0, 4, 0, 4);
+ }
+
+ /** 先发 4 字节小端长度,再发 body,和上面的 decoder 成对使用。 */
+ public static ByteBuf encodeMessage(ByteBufAllocator alloc, MessageLite message) {
+ byte[] body = message.toByteArray();
+ ByteBuf buf = alloc.buffer(4 + body.length);
+ buf.writeInt(body.length);
+ buf.writeBytes(body);
+ return buf;
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/frame/NettyBufUtil.java b/BaseModule/src/main/java/com/huangzj/base/frame/NettyBufUtil.java
new file mode 100644
index 0000000..eecb116
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/frame/NettyBufUtil.java
@@ -0,0 +1,20 @@
+package com.huangzj.base.frame;
+
+import io.netty.buffer.ByteBuf;
+
+/**
+ * 把 ByteBuf 里当前可读区间一次性拷成 byte[],handler 里少写两行样板代码。
+ */
+public final class NettyBufUtil {
+
+ private NettyBufUtil() {
+ }
+
+ /** 读指针会跟着动,注意别重复读同一段。 */
+ public static byte[] readReadableBytes(ByteBuf buf) {
+ int n = buf.readableBytes();
+ byte[] raw = new byte[n];
+ buf.readBytes(raw);
+ return raw;
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/module/AbsHostModule.java b/BaseModule/src/main/java/com/huangzj/base/module/AbsHostModule.java
new file mode 100644
index 0000000..1a50d4b
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/module/AbsHostModule.java
@@ -0,0 +1,16 @@
+package com.huangzj.base.module;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 挂在某个宿主(一般是玩家或会话)上的模块基类,手里攥着宿主引用。
+ *
+ * @param 宿主类型,demo 里是 {@link com.huangzj.server.module.DemoPlayerHost}
+ */
+@Getter
+@RequiredArgsConstructor
+public abstract class AbsHostModule
{
+
+ protected final P player;
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/module/GeneralModule.java b/BaseModule/src/main/java/com/huangzj/base/module/GeneralModule.java
new file mode 100644
index 0000000..e0d3b33
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/module/GeneralModule.java
@@ -0,0 +1,121 @@
+package com.huangzj.base.module;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 玩家侧逻辑模块的骨架:tick、存档、登录回调都先空着,子类按需填。
+ * 和修仙那套 GeneralModule 思路类似,这里做了精简,只保留 demo 要用的钩子。
+ */
+@Getter
+@Setter
+public abstract class GeneralModule
extends AbsHostModule
{
+
+ /** tick / 存盘时共用的锁,避免和业务线程打架(demo 里只用在 runTick)。 */
+ protected final Object saveLockObj = new Object();
+
+ /** 是否有脏数据要同步,具体怎么用看业务。 */
+ protected boolean needSync;
+
+ /** 是否已经进入过登录后流程,占位字段。 */
+ protected boolean enterAfterLogin;
+
+ /** 为 true 时才会被 {@link com.huangzj.base.spring.module.PlayerModuleRegistry#runTickAll(long)} 扫到。 */
+ private boolean enableTick;
+
+ protected GeneralModule(P player) {
+ super(player);
+ }
+
+ /** 标记需要同步,一般配合 syncChange 使用。 */
+ public void setNeedSync() {
+ this.needSync = true;
+ }
+
+ /** 同步做完了就清掉标记。 */
+ public void finishSync() {
+ this.needSync = false;
+ }
+
+ /** 当前模块归属的玩家 id,子类必须给出。 */
+ public abstract long getPlayerId();
+
+ /** 拉档,默认 true 表示不用拦。 */
+ public boolean loadData() {
+ return true;
+ }
+
+ /** 拉档后的二次处理,默认放行。 */
+ public boolean afterLoadData() {
+ return true;
+ }
+
+ /** 存盘,子类可写 Mongo/Redis;默认啥也不干。 */
+ public boolean saveData() {
+ return true;
+ }
+
+ /** 把内存改动推到客户端或其它服,force 是否强推由子类解释。 */
+ public void syncChange(boolean force) {
+ }
+
+ /** 模块类型,默认就是具体类。 */
+ public Class> getModuleClass() {
+ return getClass();
+ }
+
+ /** 登录完成要下发的消息,占位。 */
+ public void loginSendMsg(boolean isReconnect) {
+ }
+
+ /** 登录后逻辑,占位。 */
+ public void afterLogin(boolean isReconnect) {
+ }
+
+ /** 下线清理,占位。 */
+ public void offline() {
+ }
+
+ /** 处理游戏内推送/内部消息,占位。 */
+ public void handleGameMsg(Object msg) {
+ }
+
+ /** 红点刷新,占位。 */
+ public void refreshRedPoint() {
+ }
+
+ /** 跨天/重置类逻辑,占位。 */
+ public void dailyResetData() {
+ }
+
+ /** 打开定时 tick,注册表只会调度 isEnableTick 为 true 的模块。 */
+ public void enableTick() {
+ this.enableTick = true;
+ }
+
+ /** 若要做精准调度可覆写;默认 0 表示「不承诺下次时间点」。 */
+ public long calcNextTickAt() {
+ return 0L;
+ }
+
+ /**
+ * 外层统一包一层锁和异常,真正的周期逻辑写在 {@link #tick(long)}。
+ */
+ public void runTick(long runAt) {
+ try {
+ synchronized (saveLockObj) {
+ tick(runAt);
+ }
+ } catch (Exception e) {
+ onTickError(e);
+ }
+ }
+
+ /** 每个调度周期进来一次,子类写业务。 */
+ protected void tick(long runAt) {
+ }
+
+ /** tick 抛错时的钩子,默认吞掉,需要打日志或告警就覆写。 */
+ protected void onTickError(Exception e) {
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/module/ModuleMetricsProvider.java b/BaseModule/src/main/java/com/huangzj/base/module/ModuleMetricsProvider.java
new file mode 100644
index 0000000..dc6e4c1
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/module/ModuleMetricsProvider.java
@@ -0,0 +1,15 @@
+package com.huangzj.base.module;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * 模块要是愿意暴露点计数器之类的,就实现这个;注册表拼 /module/info 时会 merge 进来。
+ */
+public interface ModuleMetricsProvider {
+
+ /** 默认啥指标也没有,子类按需 override。 */
+ default Map metrics() {
+ return Collections.emptyMap();
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/module/PlayerModule.java b/BaseModule/src/main/java/com/huangzj/base/module/PlayerModule.java
new file mode 100644
index 0000000..ca0ac28
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/module/PlayerModule.java
@@ -0,0 +1,17 @@
+package com.huangzj.base.module;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标在 {@link GeneralModule} 上,数字越小越先跑 tick(只是 demo 约定,你可以按习惯改)。
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface PlayerModule {
+
+ /** 排序用,默认 0;相同顺序时不保证谁先谁后。 */
+ int order() default 0;
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/net/CmdRpcTcpFrameHandler.java b/BaseModule/src/main/java/com/huangzj/base/net/CmdRpcTcpFrameHandler.java
new file mode 100644
index 0000000..fde828d
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/net/CmdRpcTcpFrameHandler.java
@@ -0,0 +1,48 @@
+package com.huangzj.base.net;
+
+import com.huangzj.base.cmd.CmdRpcChannelDispatch;
+import com.huangzj.base.cmd.CommandSource;
+import com.huangzj.base.cmd.CrossRpcInvoker;
+import com.huangzj.base.cmd.GameCommandLookup;
+import com.huangzj.base.cmd.ProtoRegistry;
+import com.huangzj.base.frame.LengthPrefixedProtobuf;
+import com.huangzj.base.frame.NettyBufUtil;
+import com.huangzj.cmd.gen.CmdRpcEnvelope;
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 长度前缀剥掉后的一帧 = 整包 {@link CmdRpcEnvelope}:查 {@link ProtoRegistry} 解码并分发。
+ */
+@Slf4j
+@RequiredArgsConstructor
+public class CmdRpcTcpFrameHandler extends SimpleChannelInboundHandler {
+
+ private final GameCommandLookup lookup;
+ private final CrossRpcInvoker crossRpc;
+ private final CommandSource linkSource;
+ private final ProtoRegistry protoRegistry;
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
+ byte[] raw = NettyBufUtil.readReadableBytes(msg);
+ // 解码 + ProtoRegistry + GameCommand 执行在同一线程(默认 IO 线程),业务应避免长时间阻塞
+ CmdRpcEnvelope out =
+ CmdRpcChannelDispatch.dispatch(raw, lookup, ctx, crossRpc, linkSource, protoRegistry);
+ ctx.writeAndFlush(LengthPrefixedProtobuf.encodeMessage(ctx.alloc(), out));
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ log.error(
+ "CmdRpc TCP handler error source={} remote={}",
+ linkSource,
+ ctx.channel().remoteAddress(),
+ cause);
+ // 半包解码器之上若已错乱,继续读无意义,直接断链让客户端重连
+ ctx.close();
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/spring/SpringGameCommandRegistry.java b/BaseModule/src/main/java/com/huangzj/base/spring/SpringGameCommandRegistry.java
new file mode 100644
index 0000000..b08d7eb
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/spring/SpringGameCommandRegistry.java
@@ -0,0 +1,55 @@
+package com.huangzj.base.spring;
+
+import com.huangzj.base.cmd.Cmd;
+import com.huangzj.base.cmd.CommandSource;
+import com.huangzj.base.cmd.GameCommand;
+import com.huangzj.base.cmd.GameCommandLookup;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationContext;
+import org.springframework.util.ClassUtils;
+
+/**
+ * 启动时把 Spring 容器里的 {@link GameCommand} 扫一遍,按 {@link Cmd} 过滤来源。
+ * 当前 key 为 {@link Cmd#serviceId()} + {@link Cmd#methodId()}(与 {@link com.huangzj.cmd.gen.CmdRpcEnvelope} 一致)。
+ */
+@Slf4j
+public class SpringGameCommandRegistry implements GameCommandLookup {
+
+ private final Map commands = new HashMap<>();
+
+ /** 只收某一种来源的命令。 */
+ public SpringGameCommandRegistry(ApplicationContext applicationContext, CommandSource source) {
+ this(applicationContext, EnumSet.of(source));
+ }
+
+ /** 区服若收多种来源,用 Set 传多个 {@link CommandSource}。 */
+ public SpringGameCommandRegistry(ApplicationContext applicationContext, Set sources) {
+ Map beans = applicationContext.getBeansOfType(GameCommand.class);
+ for (GameCommand cmd : beans.values()) {
+ Class> userClass = ClassUtils.getUserClass(cmd);
+ Cmd meta = userClass.getAnnotation(Cmd.class);
+ // 同一 Bean 可能带 @Cmd(source=Client_TCP),在跨服进程扫描时会被 sources 过滤掉
+ if (meta != null && sources.contains(meta.source())) {
+ commands.put(key(meta.serviceId(), meta.methodId()), cmd);
+ }
+ }
+ log.info("GameCommand registry sources={} size={} keys={}", sources, commands.size(), commands.keySet());
+ }
+
+ private static String key(int serviceId, int methodId) {
+ return ":" + serviceId + ":" + methodId;
+ }
+
+ @Override
+ public GameCommand resolve(int serviceId, int methodId) {
+ GameCommand cmd = commands.get(key(serviceId, methodId));
+ if (cmd == null) {
+ throw new IllegalArgumentException("unknown " + serviceId + "/" + methodId);
+ }
+ return cmd;
+ }
+}
diff --git a/BaseModule/src/main/java/com/huangzj/base/spring/module/PlayerModuleRegistry.java b/BaseModule/src/main/java/com/huangzj/base/spring/module/PlayerModuleRegistry.java
new file mode 100644
index 0000000..0bdc946
--- /dev/null
+++ b/BaseModule/src/main/java/com/huangzj/base/spring/module/PlayerModuleRegistry.java
@@ -0,0 +1,85 @@
+package com.huangzj.base.spring.module;
+
+import com.huangzj.base.module.GeneralModule;
+import com.huangzj.base.module.ModuleMetricsProvider;
+import com.huangzj.base.module.PlayerModule;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.beans.BeansException;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.stereotype.Component;
+
+/**
+ * 把容器里所有 {@link GeneralModule} 捞出来排个序,统一 tick、save,顺便给 HTTP 一个快照。
+ */
+@Component
+public class PlayerModuleRegistry implements ApplicationContextAware {
+
+ /** 排好序的模块列表,只在容器刷新时重建。 */
+ private List> modules = List.of();
+
+ /**
+ * Spring 回调:收集所有 GeneralModule Bean,按 {@link PlayerModule#order()} 从小到大排。
+ */
+ @Override
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+ Map raw = applicationContext.getBeansOfType(GeneralModule.class);
+ List> list = new ArrayList<>();
+ for (GeneralModule m : raw.values()) {
+ list.add(m);
+ }
+ list.sort(Comparator.comparingInt(this::orderOf));
+ modules = List.copyOf(list);
+ }
+
+ /** 没标注解就当 0,和别的模块挤在一起。 */
+ private int orderOf(GeneralModule> module) {
+ PlayerModule ann = module.getClass().getAnnotation(PlayerModule.class);
+ return ann == null ? 0 : ann.order();
+ }
+
+ /** 当前快照,一般给调试或管理接口。 */
+ public List> all() {
+ return modules;
+ }
+
+ /** 对所有「开了 tick」的模块跑一遍。 */
+ public void runTickAll(long runAt) {
+ for (GeneralModule> module : modules) {
+ if (module.isEnableTick()) {
+ module.runTick(runAt);
+ }
+ }
+ }
+
+ /** 依次存盘,谁失败谁自己处理,这里不中断链路。 */
+ public void saveAll() {
+ for (GeneralModule> module : modules) {
+ module.saveData();
+ }
+ }
+
+ /**
+ * 拼一份给人看的列表:类名、顺序、是否 tick,外加实现了 {@link ModuleMetricsProvider} 的指标。
+ */
+ public List