Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions Content.Server/_Ganimed/Addiction/AddictionSymptomsSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// SPDX-FileCopyrightText: 2026 ultradyper <ultradyper@users.noreply.github.com>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Shared._Ganimed.Addiction;
using Content.Shared.Jittering;
using Content.Shared.StatusEffectNew;
using Robust.Shared.Timing;

namespace Content.Server._Ganimed.Addiction;

/// <summary>
/// Применяет симптомы ломки (дрожь, косноязычие, слабость, галлюцинации).
/// Слушает AddictionSymptomsChangedEvent от AddictionSystem и пересчитывает симптомы
/// по всем каналам: доза по одному каналу не снимает симптомы другого.
/// Продлевает симптомы по таймеру, пока идёт ломка.
/// </summary>
public sealed partial class AddictionSymptomsSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly SharedJitteringSystem _jitter = default!;
[Dependency] private readonly StatusEffectsSystem _status = default!;

public override void Initialize()
{
base.Initialize();

// Directed-подписка: событие рейзится на сущность (RaiseLocalEvent без broadcast),
// broadcast-подписчики в этом форке такое не получают (см. грабли #21).
SubscribeLocalEvent<AddictionComponent, AddictionSymptomsChangedEvent>(OnSymptomsChanged);
}

public override void Update(float frameTime)
{
base.Update(frameTime);

var query = EntityQueryEnumerator<AddictionComponent>();
while (query.MoveNext(out var uid, out var comp))
{
var anyWithdrawal = false;
var due = false;
foreach (var channel in comp.Channels)
{
if (!channel.InWithdrawal)
continue;

anyWithdrawal = true;
if (_timing.CurTime >= channel.NextSymptomsTime)
due = true;
}

if (!anyWithdrawal || !due)
continue;

RefreshSymptoms(uid, comp);

foreach (var channel in comp.Channels)
{
if (channel.InWithdrawal)
channel.NextSymptomsTime = _timing.CurTime + comp.SymptomRefreshInterval;
}
}
}

private void OnSymptomsChanged(EntityUid uid, AddictionComponent comp, ref AddictionSymptomsChangedEvent args)
{
RefreshSymptoms(uid, comp);
}

/// <summary>
/// Пересчитывает симптомы по всем каналам: применяет нужные, убирает лишние.
/// Stutter/Slurred/Rainbow не снимает: эти эффекты могут висеть от других источников
/// (алкоголь, ЛСД, THC), они истекают сами. Снимаются только свои: слабость
/// (уникальный прототип) и дрожь.
/// </summary>
private void RefreshSymptoms(EntityUid uid, AddictionComponent comp)
{
var anyWithdrawal = false;
var maxStage = 0;
var wantSlurred = false;
var wantStutter = false;
var wantWeakness = false;
var wantRainbow = false;

foreach (var channel in comp.Channels)
{
if (!channel.InWithdrawal)
continue;

anyWithdrawal = true;
maxStage = Math.Max(maxStage, channel.Stage);

if (channel.Stage >= 1)
{
if (channel.Kind == AddictionKind.Alcohol)
wantSlurred = true;
else
wantStutter = true;
}

if (channel.Stage >= 2)
{
wantWeakness = true;
if (channel.Kind == AddictionKind.Drug)
wantRainbow = true;
}
}

// Дрожь - косметика на любой стадии, амплитуда по самой тяжёлой.
// refresh: true, чтобы время не копилось при повторных вызовах.
if (anyWithdrawal)
{
var amplitude = maxStage switch
{
0 => comp.MildJitterAmplitude,
1 => comp.MediumJitterAmplitude,
_ => comp.SevereJitterAmplitude,
};
_jitter.DoJitter(uid, comp.SymptomDuration, refresh: true, amplitude, comp.JitterFrequency);
}
else
{
RemComp<JitteringComponent>(uid);
}
Comment thread
ultradyper marked this conversation as resolved.

if (wantSlurred)
_status.TrySetStatusEffectDuration(uid, comp.SlurredEffect, comp.SymptomDuration);

if (wantStutter)
_status.TrySetStatusEffectDuration(uid, comp.StutterEffect, comp.SymptomDuration);

if (wantWeakness)
_status.TrySetStatusEffectDuration(uid, comp.WeaknessEffect, comp.SymptomDuration);
else
_status.TryRemoveStatusEffect(uid, comp.WeaknessEffect);

if (wantRainbow)
_status.TrySetStatusEffectDuration(uid, comp.RainbowEffect, comp.SymptomDuration);
}
}

213 changes: 213 additions & 0 deletions Content.Server/_Ganimed/Addiction/AddictionSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// SPDX-FileCopyrightText: 2026 ultradyper <ultradyper@users.noreply.github.com>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using System.Linq;
using Content.Server.Body.Systems;
using Content.Server.Popups;
using Content.Server.Traits;
using Content.Shared.GameTicking;
using Content.Shared._Ganimed.Addiction;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.Mobs.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;

namespace Content.Server._Ganimed.Addiction;

/// <summary>
/// Система зависимости: ловит приём доз (GetReagentEffectsEvent), копит уровень привыкания,
/// при долгом воздержании запускает ломку и рейзит AddictionSymptomsChangedEvent,
/// чтобы симптомы применила AddictionSymptomsSystem.
/// Компонент есть у всех игроков со спавна, канал зависимости появляется
/// при первом употреблении (подсесть может каждый), трайты дают стартовую
/// зависимость с высоким уровнем.
/// </summary>
public sealed partial class AddictionSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly PopupSystem _popup = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;

public override void Initialize()
{
base.Initialize();

SubscribeLocalEvent<AddictionComponent, GetReagentEffectsEvent>(OnGetReagentEffects);
SubscribeLocalEvent<AddictionComponent, ComponentInit>(OnComponentInit);
// After TraitSystem: EnsureComp не должен перебить каналы, добавленные трайтом
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete, after: [typeof(TraitSystem)]);
}

public override void Update(float frameTime)
{
base.Update(frameTime);

var query = EntityQueryEnumerator<AddictionComponent>();
while (query.MoveNext(out var uid, out var comp))
{
UpdateAddiction(uid, comp, frameTime);
}
}

private void OnComponentInit(EntityUid uid, AddictionComponent comp, ComponentInit args)
{
// Каналы из трайта приходят с LastDoseTime = 0, иначе ломка началась бы мгновенно.
// Уровень выше порога означает, что зависимость уже есть: без WasAddicted
// лечение не показало бы поп-ап выздоровления, а доза до лечения - поп-ап подсадки.
foreach (var channel in comp.Channels)
{
if (channel.LastDoseTime == TimeSpan.Zero)
channel.LastDoseTime = _timing.CurTime;

if (channel.Level >= comp.Threshold)
channel.WasAddicted = true;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void UpdateAddiction(EntityUid uid, AddictionComponent comp, float frameTime)
{
var dead = _mobState.IsDead(uid);

foreach (var channel in comp.Channels)
{
// Привыкание медленно спадает само
channel.Level = MathF.Max(0f, channel.Level - comp.DecayRate * frameTime);

var timeSinceDose = _timing.CurTime - channel.LastDoseTime;

// Доза была недавно - ломки нет
if (timeSinceDose < comp.WithdrawalDelay)
{
if (channel.InWithdrawal)
{
channel.InWithdrawal = false;
channel.Stage = 0;
RaiseSymptomsChanged(uid);
}
continue;
}

// Уровень упал ниже порога - зависимость отпустила
if (channel.Level <= comp.Threshold)
{
if (channel.WasAddicted)
{
channel.WasAddicted = false;
if (!dead)
_popup.PopupEntity(Loc.GetString($"addiction-cured-{KindLoc(channel.Kind)}"), uid, uid);
}

if (channel.InWithdrawal)
{
channel.InWithdrawal = false;
channel.Stage = 0;
RaiseSymptomsChanged(uid);
}
continue;
}

if (dead)
continue;

// Ломка
var stageTime = timeSinceDose - comp.WithdrawalDelay;
var stage = stageTime < comp.MildStageDuration
? 0
: stageTime < comp.MildStageDuration + comp.MediumStageDuration
? 1
: 2;

if (!channel.InWithdrawal || channel.Stage != stage)
{
channel.InWithdrawal = true;
channel.Stage = stage;
RaiseSymptomsChanged(uid);
}

if (_timing.CurTime >= channel.NextPopupTime)
{
channel.NextPopupTime = _timing.CurTime + comp.PopupInterval;
_popup.PopupEntity(Loc.GetString($"addiction-withdrawal-{KindLoc(channel.Kind)}-{stage}"), uid, uid);
}
}
}

private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent args)
{
// Любой игрок может подсесть: компонент есть у всех с момента спавна
EnsureComp<AddictionComponent>(args.Mob);
}

private void OnGetReagentEffects(EntityUid uid, AddictionComponent comp, ref GetReagentEffectsEvent args)
{
var kind = GetKind(args.Reagent, comp);
if (kind is not { } kindValue)
return;

var channel = comp.Channels.FirstOrDefault(c => c.Kind == kindValue);
if (channel == null)
{
channel = new AddictionChannel { Kind = kindValue };
comp.Channels.Add(channel);
}

channel.Level = MathF.Min(100f, channel.Level + comp.GainPerTick);
channel.LastDoseTime = _timing.CurTime;
channel.NextPopupTime = TimeSpan.Zero;

// Доза снимает ломку (поп-ап только если ломка реально была)
if (channel.InWithdrawal)
{
channel.InWithdrawal = false;
channel.Stage = 0;
RaiseSymptomsChanged(uid);
_popup.PopupEntity(Loc.GetString($"addiction-dose-{KindLoc(kindValue)}"), uid, uid);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// Первое превышение порога - подсадка
else if (!channel.WasAddicted && channel.Level >= comp.Threshold)
{
channel.WasAddicted = true;
_popup.PopupEntity(Loc.GetString($"addiction-begin-{KindLoc(kindValue)}"), uid, uid);
}
}

/// <summary>
/// Определяет тип зависимости по рецепту: никотин по id, алкоголь по группе Alcohol,
/// наркотики по группе Narcotic. Группы и реагент настраиваются в компоненте.
/// </summary>
private AddictionKind? GetKind(ReagentId reagent, AddictionComponent comp)
{
if (reagent.Prototype == comp.NicotineReagent)
return AddictionKind.Nicotine;

if (!_proto.TryIndex(reagent.Prototype, out ReagentPrototype? proto) || proto.Metabolisms is not { } metabolisms)
return null;

if (metabolisms.ContainsKey(comp.AlcoholMetabolismGroup))
return AddictionKind.Alcohol;

if (metabolisms.ContainsKey(comp.NarcoticMetabolismGroup))
return AddictionKind.Drug;

return null;
}

/// <summary>
/// Сообщает AddictionSymptomsSystem, что набор симптомов нужно пересчитать.
/// </summary>
private void RaiseSymptomsChanged(EntityUid uid)
{
var ev = new AddictionSymptomsChangedEvent(uid);
RaiseLocalEvent(uid, ref ev);
}

private static string KindLoc(AddictionKind kind) => kind switch
{
AddictionKind.Alcohol => "alcohol",
AddictionKind.Nicotine => "nicotine",
AddictionKind.Drug => "drug",
_ => "alcohol",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 ultradyper <ultradyper@users.noreply.github.com>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Shared._Ganimed.Addiction;
using Content.Shared._Ganimed.Addiction.Effects;
using Content.Shared.EntityEffects;

namespace Content.Server._Ganimed.Addiction;

/// <summary>
/// Применяет эффект AdjustAddictionLevel к компоненту зависимости.
/// </summary>
public sealed partial class AdjustAddictionLevelEffectSystem : EntityEffectSystem<AddictionComponent, AdjustAddictionLevel>
{
protected override void Effect(Entity<AddictionComponent> entity, ref EntityEffectEvent<AdjustAddictionLevel> args)
{
var amount = args.Effect.Amount * args.Scale;

foreach (var channel in entity.Comp.Channels)
{
if (args.Effect.Kind is { } kind && channel.Kind != kind)
continue;

channel.Level = MathF.Max(0f, channel.Level + amount);
}
}
}
Loading
Loading