using Ananta.SDK.Network;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
///
/// Party-camp furniture the server places itself — 露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车.
///
/// WHY (2026-09-26, round 44). 用户站在露营点 2.8 m 处实测「家具全无」:烤炉、帐篷一件都没有,
/// 而我们**确实**把营地那一格(32,59)通过 grid-AOI 下发过了。原因和健身房器材一模一样:
/// 这批 `Gadgets_Party_*` 只活在客户端的关卡 blob(LX6.Item.GadgetBlob)里,而
/// SyncGadgetGridAOIDecrease(68956797) 这条下发**不驱动**
/// GadgetBlob.OnAoiAddBlobCfgByGridIndex ⇒ 烘焙里的道具一件都不会自己出现。
/// 用户那句「是不是和健身器材一样」是对的。见 。
///
/// ★ 判据(回答「是不是开派对才刷」):**不是**。家具烘焙在 raid 23300888 = 主城 free-roam,
/// 而露营/舞会的 PartyConfig.RaidId 也是 23300888 ⇒ 派对就地举行、不切副本 ⇒ 场地家具只能是
/// 预先常驻的世界装点。旁证:同一批 prefab 还被当作天台夜市(-378.28,130.50,-497.87)的
/// 常驻装点摆着;营地 400 m 圈内全是改车店/公厕/时装店/象棋摊等主城常驻机关。
/// ⇒ 所以这里是**常驻摆放**(玩家走进场地半径就摆),而不是「开派对时才摆」。
///
/// ⛔ PackedGadgetInfo.iScale 必须是 0(100 ⇒ transform scale (0,0,1) 零尺寸空壳)。
///
/// Rollback: world.content.partyCamp.enabled = false.
///
internal sealed partial class GameRouter
{
private const string PartyCampKey = "client4229938-party-camp";
internal sealed class PartyCampSessionState
{
internal object SyncRoot { get; } = new();
/// Camp sites already stocked this session. One shot per site, like the gym.
internal HashSet Placed { get; } = new(StringComparer.Ordinal);
internal int PlacedCount;
internal bool WarnedEmpty;
// ★ 2026-09-30 就地开局:把整片场地临时搬到玩家脚下(0 = 未启用,走配表锚点)。
internal uint VenueAtPlayerPartyId;
internal float VenueAtPlayerX;
internal float VenueAtPlayerY;
internal float VenueAtPlayerZ;
///
/// 就地开局那一刻玩家的朝向(度)。家具整片靠它旋转到玩家正前方
/// (见 )。0 = 保持官方世界方位。
///
internal float VenueAtPlayerFacingDeg;
}
///
/// ★★★ 2026-09-30:**就地开局** —— 把指定 partyId 的整片场地搬到玩家脚下。
///
///
/// 官方把舞会 13030003 摆在 y ≈ 80 的悬空层(那层没有地板,实机够不到),
/// 所以开派对时由 的 StartSingleParty 分支调本方法,
/// 把家具整片平移到玩家坐标;下一次 grid tick 就会按新坐标摆出来。
///
/// ⛑⛑ 必须同时把场地从 Placed 里摘掉:
/// Placed 是「每会话每场地只摆一次」的去重 HashSet,而一次误判
/// (玩家临时站在矩形外 ⇒ INDOOR-GATE 全挡)就会把场地永久毒死
/// (2026-09-30 踩过,见 2026-09-30.md §45.2)。
/// 同理,同一会话重开派对时也必须解掉,否则第二场一件家具都没有。
///
/// 传入的 必须是地面基准(玩家上报 y − 眼高 ≈ 1.07 m),
/// 不是玩家上报值 —— 角色上报的是胶囊中心,直接用会整体浮空 1 m。
///
/// 是开派对那一刻玩家的朝向(度)。partyVenuePropFacePlayer
/// 打开时,家具整片会按它旋转,让最远那件(舞会 = 灯光控制台)落在玩家正前方;
/// 传 0(或开关关闭)则保持官方的世界方位。
///
internal static void SetPartyVenueAtPlayer(
TcpSession session, uint partyId, float x, float y, float z, float facingDeg)
{
var owner = PartyCampStateOf(session);
lock (owner.SyncRoot)
{
owner.VenueAtPlayerPartyId = partyId;
owner.VenueAtPlayerX = x;
owner.VenueAtPlayerY = y;
owner.VenueAtPlayerZ = z;
owner.VenueAtPlayerFacingDeg = facingDeg;
foreach (var s in PartyCampCatalog4229938.Sites)
{
if (s.PartyId == partyId)
owner.Placed.Remove(s.Name);
}
}
}
private static PartyCampSessionState PartyCampStateOf(TcpSession session)
{
if (session.Items.TryGetValue(PartyCampKey, out var existing)
&& existing is PartyCampSessionState typed)
return typed;
var created = new PartyCampSessionState();
session.Items[PartyCampKey] = created;
return created;
}
/// Reconnect / scene switch: forget what was stocked so the next visit re-places it.
internal static void ResetPartyCamp(TcpSession session)
{
var owner = PartyCampStateOf(session);
lock (owner.SyncRoot)
{
owner.Placed.Clear();
owner.PlacedCount = 0;
owner.WarnedEmpty = false;
}
}
///
/// Called from the grid-AOI tick (which already knows the player position). Places every camp
/// the player has walked into, once per camp per session.
///
internal static async Task TickPartyCampAsync(
TcpSession session, WorldEntryState state, float px, float pz)
{
var cfg = PrivateServerConfigStore.Current.World.Content.PartyCamp;
if (!cfg.Enabled)
return;
var owner = PartyCampStateOf(session);
if (PartyCampCatalog4229938.Count == 0)
{
// One line per session instead of per tick — a missing table is a setup step.
lock (owner.SyncRoot)
{
if (owner.WarnedEmpty)
return;
owner.WarnedEmpty = true;
}
session.Log.Info($"[PARTYCAMP] 没有可用场地:{PartyCampCatalog4229938.LoadError}");
return;
}
var channel = cfg.Channel switch
{
1 => GadgetSendChannel.Aoi,
2 => GadgetSendChannel.Both,
3 => GadgetSendChannel.AddList,
_ => GadgetSendChannel.Add,
};
foreach (var site in PartyCampCatalog4229938.WithinRadius(px, pz, 0))
{
// ★★★ 2026-09-30 就地开局:整片场地搬到玩家脚下(见 SetPartyVenueAtPlayer)。
// WithinRadius(...,0) 返回**全部**场地(半径 > 0 才过滤),所以远处开局也照样遍历得到。
float anchorX = site.X, anchorY = site.Y, anchorZ = site.Z;
var venueAtPlayer = false;
var venueFacingDeg = 0f;
lock (owner.SyncRoot)
{
if (owner.VenueAtPlayerPartyId != 0 && owner.VenueAtPlayerPartyId == site.PartyId)
{
anchorX = owner.VenueAtPlayerX;
anchorY = owner.VenueAtPlayerY;
anchorZ = owner.VenueAtPlayerZ;
venueFacingDeg = owner.VenueAtPlayerFacingDeg;
venueAtPlayer = true;
}
}
var radius = site.Radius + cfg.RadiusBonus;
if (radius > 0f)
{
var dx = anchorX - px;
var dz = anchorZ - pz;
if (dx * dx + dz * dz > radius * radius)
continue;
}
lock (owner.SyncRoot)
{
if (!owner.Placed.Add(site.Name))
continue;
}
// 家具跟着锚点整体平移(相对摆位照抄官方,只是换了个落脚点)。
// ⛑ 这里先算出列表,真正赋给 `items` 要等它声明之后(闸门那段会 reassign)。
IReadOnlyList? shiftedItems = null;
if (venueAtPlayer)
{
// ★ 就地开局:以**动态锚**为原点重放官方的相对摆位。
// `partyVenuePropMaxDistance > 0` 时再把水平距离钳到该上限(只收距离、不改方向),
// 否则官方那 20 m 外的灯光控制台搬到脚下依然够不到。
var clamp = PrivateServerConfigStore.Current.Gameplay.PartyVenuePropMaxDistance;
// ★★ 朝向对齐(2026-09-30):官方布局的方位是**写死的世界方向**
// (舞会灯光控制台在锚点的 +x/+z、约 76°)。整片平移到玩家脚下后方位不变
// ⇒ 玩家很可能背对着它,实测反馈「看不到控制台」(它在 6 m 外,却在身后)。
// 这里以「离锚点最远那件」当枢轴,把整片相对摆位**旋转**到玩家正前方。
// ⛑ 方位角约定与 NPC 散布一致:方向向量 = (sin θ, cos θ) ⇒ θ = atan2(x, z)。
var yaw = 0f;
if (PrivateServerConfigStore.Current.Gameplay.PartyVenuePropFacePlayer)
{
float pivotBearing = 0f, pivotDist2 = -1f;
var pivotName = string.Empty;
foreach (var it in site.Items)
{
var ox0 = it.X - site.X;
var oz0 = it.Z - site.Z;
var d2 = ox0 * ox0 + oz0 * oz0;
if (d2 > pivotDist2)
{
pivotDist2 = d2;
pivotBearing = MathF.Atan2(ox0, oz0) * 180f / MathF.PI;
pivotName = it.Name;
}
}
if (pivotDist2 > 0f)
{
var delta = venueFacingDeg - pivotBearing;
yaw = delta * MathF.PI / 180f;
session.Log.Info($"[PARTYCAMP] 就地开局朝向对齐:枢轴={pivotName} " +
$"官方方位={pivotBearing:F1}°、玩家朝向={venueFacingDeg:F1}° " +
$"⇒ 整片旋转 {delta:F1}°(最远那件落到玩家正前方)");
}
}
var cosY = MathF.Cos(yaw);
var sinY = MathF.Sin(yaw);
var moved = new List(site.Items.Count);
foreach (var it in site.Items)
{
var ox = it.X - site.X;
var oy = it.Y - site.Y;
var oz = it.Z - site.Z;
// 旋转(保长度,只改方位):(sinθ,cosθ)·d → (sin(θ+yaw),cos(θ+yaw))·d
if (yaw != 0f)
{
var rx = ox * cosY + oz * sinY;
var rz = -ox * sinY + oz * cosY;
ox = rx;
oz = rz;
}
if (clamp > 0f)
{
var d = MathF.Sqrt(ox * ox + oz * oz);
if (d > clamp && d > 0f)
{
var k = clamp / d;
ox *= k;
oz *= k;
}
}
moved.Add(it with { X = anchorX + ox, Y = anchorY + oy, Z = anchorZ + oz });
}
shiftedItems = moved;
}
var items = shiftedItems ?? site.Items;
// ★ 2026-09-30:按 pathId 跳过不想摆的家具(例:舞会那道「离开舞会的门」)。
// `world.content.partyCamp.skipPathIds`,默认空 = 一件不少。
if (cfg.SkipPathIds.Length > 0)
{
var kept0 = new List(items.Count);
foreach (var it in items)
{
// ⛑ PathId 是 int、配置是 uint[] ⇒ 必须显式转换,否则 Enumerable.Contains
// 匹配不上(CS1929:只会去找 MemoryExtensions.Contains)。
if (cfg.SkipPathIds.Contains((uint)it.PathId))
{
session.Log.Info($"[PARTYCAMP] {site.Name} 家具 {it.Name}({it.PathId}) " +
$"在 skipPathIds 里 ⇒ 跳过");
continue;
}
kept0.Add(it);
}
items = kept0;
}
// ★★★ 第 50 轮(室内层判定):场地家具逐件过门。
// 落在「VisibleOutside = false」的室内占地里、而玩家不在其中的那几件,
// **不摆**。标本:舞会场地(danceOnly)的两件常驻机关
// (Gadgets_Party_DanceDoor 514708797 / Gadgets_Party_LightControl 1341214683)
// 在 IndoorConfig#150 "Spiko Hotel" 的占地里,我们却把它们摆到露天 y≈80
// ⇒ 现场「道具在半空、地板不在」。
if (PrivateServerConfigStore.Current.Gameplay.IndoorVisibilityGate)
{
var kept = new List(items.Count);
var held = 0;
foreach (var it in items)
{
if (IndoorRoomCatalog4229938.TryFindHiddenAt(it.X, it.Z, site.Raid, out var hiddenItemRoom)
&& !hiddenItemRoom.Contains(px, pz))
{
held++;
session.Log.Warn($"[INDOOR-GATE] {site.Name} 家具 {it.Name}({it.PathId}) " +
$"@({it.X:F1},{it.Y:F1},{it.Z:F1}) 位于不可见室内 " +
$"{hiddenItemRoom.Name}(IndoorConfig#{hiddenItemRoom.Id})且玩家不在其中" +
$" ⇒ 本件不摆。");
continue;
}
kept.Add(it);
}
items = kept;
if (held > 0 && items.Count == 0)
{
session.Log.Warn($"[INDOOR-GATE] {site.Name} 整个场地都在不可见室内 ⇒ 本批一件都不摆" +
$"(不再按露天刷)。想恢复:gameplay.indoorVisibilityGate=false。");
}
}
session.Log.Info($"[PARTYCAMP] 进入 {site.Name}(partyId={site.PartyId} raid={site.Raid} " +
$"锚点 {anchorX:F1},{anchorY:F1},{anchorZ:F1} 半径 {radius:F0}m" +
(venueAtPlayer ? " ★就地开局(玩家坐标)" : "") + ")," +
$"放置 {items.Count} 件家具 channel={channel} logOnly={cfg.LogOnly}" +
(items.Count == site.Items.Count
? ""
: $"(原始 {site.Items.Count} 件,室内层判定挡掉 {site.Items.Count - items.Count} 件)"));
if (cfg.LogOnly)
continue;
var placed = 0;
if (channel == GadgetSendChannel.AddList)
{
var specs = items
.Select(i => new GadgetSpawnSpec(i.PathId, i.X, i.Y, i.Z, i.Yaw, i.UniqueId, i.Name))
.ToList();
var batch = await SpawnGadgetListAsync(session, state, specs, cfg.IScale);
placed = batch.Ok ? batch.Count : 0;
if (!batch.Ok)
session.Log.Warn($"[PARTYCAMP] {site.Name} 批量放置失败:{batch.Message}");
}
else
{
foreach (var item in items)
{
// uniqueId 0 ⇒ 服务端自己 mint 一个(PartyCamp.json 里那些 88 B 记录没有 uid)。
var result = await SpawnGadgetAsync(
session, state, item.PathId, item.X, item.Y, item.Z, item.Yaw, channel,
item.UniqueId, cfg.IScale);
if (result.Ok)
placed++;
else
session.Log.Warn($"[PARTYCAMP] {site.Name} {item.Name}({item.PathId}) 放置失败:{result.Message}");
}
}
lock (owner.SyncRoot)
owner.PlacedCount += placed;
session.Log.Info($"[PARTYCAMP] {site.Name} 放置完成 {placed}/{items.Count}" +
(placed == items.Count ? "" : $"({items.Count - placed} 件没发出去)"));
}
}
}