mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2025-12-09 16:45:44 -07:00
Deep wilderness threats now continue at next login if present when logging out
Decreased brawler gloves and PvP gear drop rate from ordinary deep wilderness NPCs (does not apply to revenants or chaos elemental) Brawler gloves and PvP gear drops from ordinary deep wilderness NPCs now require at least 100k high alchemy risk (does not apply to revenants or chaos elemental)
This commit is contained in:
parent
4276ed731d
commit
a0435fb890
8 changed files with 159 additions and 21 deletions
|
|
@ -14,6 +14,7 @@ import core.game.node.item.Item
|
||||||
import core.game.system.command.Privilege
|
import core.game.system.command.Privilege
|
||||||
import core.game.system.timer.PersistTimer
|
import core.game.system.timer.PersistTimer
|
||||||
import core.game.system.timer.impl.Disease
|
import core.game.system.timer.impl.Disease
|
||||||
|
import core.game.world.map.path.Pathfinder
|
||||||
import core.game.world.map.zone.impl.WildernessZone
|
import core.game.world.map.zone.impl.WildernessZone
|
||||||
import core.game.world.update.flag.context.Graphics
|
import core.game.world.update.flag.context.Graphics
|
||||||
import core.tools.RandomFunction
|
import core.tools.RandomFunction
|
||||||
|
|
@ -45,6 +46,7 @@ class DWThreatTimer : PersistTimer(1, "dw-threat"), Commands {
|
||||||
var ticksLeft = 0
|
var ticksLeft = 0
|
||||||
var lastMessage = 0
|
var lastMessage = 0
|
||||||
var currentRev: NPC? = null
|
var currentRev: NPC? = null
|
||||||
|
var forceSpawn = false
|
||||||
|
|
||||||
override fun run(entity: Entity): Boolean {
|
override fun run(entity: Entity): Boolean {
|
||||||
if (ticksLeft-- <= 0) return false
|
if (ticksLeft-- <= 0) return false
|
||||||
|
|
@ -61,7 +63,8 @@ class DWThreatTimer : PersistTimer(1, "dw-threat"), Commands {
|
||||||
else if (ticksLeft >= 500) 1500
|
else if (ticksLeft >= 500) 1500
|
||||||
else 2_000_000
|
else 2_000_000
|
||||||
|
|
||||||
if ((currentRev == null || DeathTask.isDead(currentRev) || !currentRev!!.isActive) && RandomFunction.roll(rollchance)) {
|
if ((currentRev == null || DeathTask.isDead(currentRev) || !currentRev!!.isActive) && (forceSpawn || RandomFunction.roll(rollchance))) {
|
||||||
|
forceSpawn = false
|
||||||
val type = RevenantType.getClosestHigherOrEqual(entity.properties.currentCombatLevel)
|
val type = RevenantType.getClosestHigherOrEqual(entity.properties.currentCombatLevel)
|
||||||
val npc = NPC.create(type.ids.random(), entity.location)
|
val npc = NPC.create(type.ids.random(), entity.location)
|
||||||
npc.isRespawn = false
|
npc.isRespawn = false
|
||||||
|
|
@ -80,10 +83,12 @@ class DWThreatTimer : PersistTimer(1, "dw-threat"), Commands {
|
||||||
|
|
||||||
override fun save(root: JSONObject, entity: Entity) {
|
override fun save(root: JSONObject, entity: Entity) {
|
||||||
root["threat-time-remaining"] = ticksLeft.toString()
|
root["threat-time-remaining"] = ticksLeft.toString()
|
||||||
|
root["threat-forceSpawn"] = (currentRev != null).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parse(root: JSONObject, entity: Entity) {
|
override fun parse(root: JSONObject, entity: Entity) {
|
||||||
ticksLeft = root["threat-time-remaining"]?.toString()?.toIntOrNull() ?: 0
|
ticksLeft = root["threat-time-remaining"]?.toString()?.toIntOrNull() ?: 0
|
||||||
|
forceSpawn = root["threat-forceSpawn"]?.toString()?.toBoolean() ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun defineCommands() {
|
override fun defineCommands() {
|
||||||
|
|
@ -140,4 +145,8 @@ class RevGuardianBehavior : NPCBehavior() {
|
||||||
val timer = getOrStartTimer<DWThreatTimer>(target)
|
val timer = getOrStartTimer<DWThreatTimer>(target)
|
||||||
timer.ticksLeft = 0
|
timer.ticksLeft = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getPathfinderOverride(self: NPC): Pathfinder? {
|
||||||
|
return Pathfinder.SMART
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import core.game.node.Node;
|
||||||
import core.game.node.entity.Entity;
|
import core.game.node.entity.Entity;
|
||||||
import core.game.node.entity.impl.WalkingQueue;
|
import core.game.node.entity.impl.WalkingQueue;
|
||||||
import core.game.node.entity.npc.NPC;
|
import core.game.node.entity.npc.NPC;
|
||||||
|
import core.game.node.entity.npc.NPCBehavior;
|
||||||
import core.game.node.entity.player.Player;
|
import core.game.node.entity.player.Player;
|
||||||
import core.game.system.task.Pulse;
|
import core.game.system.task.Pulse;
|
||||||
import core.game.world.map.Direction;
|
import core.game.world.map.Direction;
|
||||||
|
|
@ -175,6 +176,11 @@ public abstract class MovementPulse extends Pulse {
|
||||||
if (pathfinder == null) {
|
if (pathfinder == null) {
|
||||||
if (mover instanceof Player) {
|
if (mover instanceof Player) {
|
||||||
this.pathfinder = Pathfinder.SMART;
|
this.pathfinder = Pathfinder.SMART;
|
||||||
|
} else if (mover instanceof NPC) {
|
||||||
|
NPC npc = (NPC)mover;
|
||||||
|
NPCBehavior behavior = npc.behavior;
|
||||||
|
Pathfinder pf = behavior != null ? behavior.getPathfinderOverride(npc) : null;
|
||||||
|
this.pathfinder = pf != null ? pf : Pathfinder.DUMB;
|
||||||
} else {
|
} else {
|
||||||
this.pathfinder = Pathfinder.DUMB;
|
this.pathfinder = Pathfinder.DUMB;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ public final class Properties {
|
||||||
/**
|
/**
|
||||||
* The entity's combat pulse.
|
* The entity's combat pulse.
|
||||||
*/
|
*/
|
||||||
private final CombatPulse combatPulse;
|
private CombatPulse combatPulse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the entity is retaliating.
|
* If the entity is retaliating.
|
||||||
|
|
@ -332,6 +332,14 @@ public final class Properties {
|
||||||
return combatPulse;
|
return combatPulse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the combatPulse.
|
||||||
|
* @return The void.
|
||||||
|
*/
|
||||||
|
public void setCombatPulse(CombatPulse combatPulse) {
|
||||||
|
this.combatPulse = (CombatPulse)combatPulse;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the retaliating.
|
* Gets the retaliating.
|
||||||
* @return The retaliating.
|
* @return The retaliating.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import core.game.interaction.MovementPulse;
|
||||||
import core.game.node.entity.Entity;
|
import core.game.node.entity.Entity;
|
||||||
import core.game.node.entity.combat.BattleState;
|
import core.game.node.entity.combat.BattleState;
|
||||||
import core.game.node.entity.combat.spell.CombatSpell;
|
import core.game.node.entity.combat.spell.CombatSpell;
|
||||||
|
import core.game.node.entity.combat.CombatPulse;
|
||||||
import core.game.node.entity.combat.CombatStyle;
|
import core.game.node.entity.combat.CombatStyle;
|
||||||
import core.game.node.entity.combat.spell.DefaultCombatSpell;
|
import core.game.node.entity.combat.spell.DefaultCombatSpell;
|
||||||
import core.game.node.entity.combat.equipment.WeaponInterface;
|
import core.game.node.entity.combat.equipment.WeaponInterface;
|
||||||
|
|
@ -187,6 +188,7 @@ public class NPC extends Entity {
|
||||||
*/
|
*/
|
||||||
public static NPC create(int id, Location location, Direction direction, Object... objects) {
|
public static NPC create(int id, Location location, Direction direction, Object... objects) {
|
||||||
NPC n = AbstractNPC.forId(id);
|
NPC n = AbstractNPC.forId(id);
|
||||||
|
|
||||||
if (n != null) {
|
if (n != null) {
|
||||||
n = ((AbstractNPC) n).construct(id, location, objects);
|
n = ((AbstractNPC) n).construct(id, location, objects);
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +231,8 @@ public class NPC extends Entity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
behavior.onCreation(this);
|
behavior.onCreation(this);
|
||||||
|
// FIXME: hack around MovementPulse's constructor getting run while behavior is null when behavior is set between NPC constructor and init.
|
||||||
|
getProperties().setCombatPulse(new CombatPulse(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import core.game.node.entity.combat.BattleState
|
||||||
import core.game.node.entity.combat.CombatStyle
|
import core.game.node.entity.combat.CombatStyle
|
||||||
import core.game.node.entity.combat.CombatSwingHandler
|
import core.game.node.entity.combat.CombatSwingHandler
|
||||||
import core.game.world.map.path.ClipMaskSupplier
|
import core.game.world.map.path.ClipMaskSupplier
|
||||||
|
import core.game.world.map.path.Pathfinder
|
||||||
|
|
||||||
open class NPCBehavior(vararg val ids: Int = intArrayOf()) : ContentInterface {
|
open class NPCBehavior(vararg val ids: Int = intArrayOf()) : ContentInterface {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -134,6 +135,13 @@ open class NPCBehavior(vararg val ids: Int = intArrayOf()) : ContentInterface {
|
||||||
*/
|
*/
|
||||||
open fun getSwingHandlerOverride(self: NPC, original: CombatSwingHandler) : CombatSwingHandler {return original}
|
open fun getSwingHandlerOverride(self: NPC, original: CombatSwingHandler) : CombatSwingHandler {return original}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by MovementPulse to determine if a non-default Pathfinder should be used (e.g. whether this npc should intelligently walk around obstacles)
|
||||||
|
*/
|
||||||
|
open fun getPathfinderOverride(self: NPC): Pathfinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by pathfinding code to determine the clipping mask supplier this NPC should use. You can use this to ignore water, etc.
|
* Called by pathfinding code to determine the clipping mask supplier this NPC should use. You can use this to ignore water, etc.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package core.game.node.entity.player.link;
|
package core.game.node.entity.player.link;
|
||||||
|
|
||||||
|
import core.game.container.Container;
|
||||||
|
import core.game.container.ContainerEvent;
|
||||||
|
import core.game.container.ContainerListener;
|
||||||
import core.game.node.entity.Entity;
|
import core.game.node.entity.Entity;
|
||||||
import core.game.node.entity.player.Player;
|
import core.game.node.entity.player.Player;
|
||||||
|
import core.game.node.item.Item;
|
||||||
import core.ServerConstants;
|
import core.ServerConstants;
|
||||||
import static core.api.ContentAPIKt.*;
|
import static core.api.ContentAPIKt.*;
|
||||||
|
|
||||||
|
|
@ -17,6 +21,35 @@ import static core.game.world.map.zone.impl.WildernessZone.WILDERNESS_PROT_ATTR;
|
||||||
* @author Emperor
|
* @author Emperor
|
||||||
*/
|
*/
|
||||||
public final class SkullManager {
|
public final class SkullManager {
|
||||||
|
public enum SkullIcon {
|
||||||
|
NONE(-1),
|
||||||
|
WHITE(0),
|
||||||
|
RED(1),
|
||||||
|
BH_RED5(2),
|
||||||
|
BH_BLUE4(3),
|
||||||
|
BH_GREEN3(4),
|
||||||
|
BH_GREY2(5),
|
||||||
|
BH_BROWN1(6),
|
||||||
|
SCREAM(7);
|
||||||
|
public final int id;
|
||||||
|
SkullIcon(int id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
public static SkullIcon forId(int id) {
|
||||||
|
switch(id) {
|
||||||
|
case 0: return SkullIcon.WHITE;
|
||||||
|
case 1: return SkullIcon.RED;
|
||||||
|
case 2: return SkullIcon.BH_RED5;
|
||||||
|
case 3: return SkullIcon.BH_BLUE4;
|
||||||
|
case 4: return SkullIcon.BH_GREEN3;
|
||||||
|
case 5: return SkullIcon.BH_GREY2;
|
||||||
|
case 6: return SkullIcon.BH_BROWN1;
|
||||||
|
case 7: return SkullIcon.SCREAM;
|
||||||
|
default: return SkullIcon.NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player instance.
|
* The player instance.
|
||||||
|
|
@ -198,15 +231,74 @@ public final class SkullManager {
|
||||||
return skulled || deepWilderness;
|
return skulled || deepWilderness;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isDeepWilderness() {
|
public boolean isDeepWilderness() {
|
||||||
return deepWilderness;
|
return deepWilderness;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setDeepWilderness (boolean deepWildy) {
|
public void setDeepWilderness (boolean deepWildy) {
|
||||||
setSkullIcon(deepWildy ? 1 : skulled ? 0 : -1);
|
if(deepWildy) {
|
||||||
setSkullCheckDisabled(deepWildy);
|
updateDWSkullIcon();
|
||||||
deepWilderness = deepWildy;
|
} else {
|
||||||
|
removeDWSkullIcon();
|
||||||
|
}
|
||||||
|
setSkullCheckDisabled(deepWildy);
|
||||||
|
deepWilderness = deepWildy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final long DEEP_WILD_DROP_RISK_THRESHOLD = 100000;
|
||||||
|
public void updateDWSkullIcon() {
|
||||||
|
if (player.getAttribute("deepwild-value-listener") == null) {
|
||||||
|
ContainerListener listener = new ContainerListener() {
|
||||||
|
@Override
|
||||||
|
public void update(Container c, ContainerEvent event) {
|
||||||
|
refresh(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void refresh(Container c) {
|
||||||
|
updateDWSkullIcon();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
player.setAttribute("deepwild-value-listener", listener);
|
||||||
|
player.getInventory().getListeners().add(listener);
|
||||||
|
player.getEquipment().getListeners().add(listener);
|
||||||
}
|
}
|
||||||
|
long value = 0;
|
||||||
|
long maxValue = 0;
|
||||||
|
for (Item item : player.getInventory().toArray()) {
|
||||||
|
if (item != null) {
|
||||||
|
long alchValue = item.getAlchemyValue();
|
||||||
|
value += alchValue;
|
||||||
|
maxValue = Math.max(maxValue, alchValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Item item : player.getEquipment().toArray()) {
|
||||||
|
if (item != null) {
|
||||||
|
long alchValue = item.getAlchemyValue();
|
||||||
|
value += alchValue;
|
||||||
|
maxValue = Math.max(maxValue, alchValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Act as if protect item is always active when calculating risk
|
||||||
|
value -= maxValue;
|
||||||
|
player.setAttribute("deepwild-value-risk", value);
|
||||||
|
SkullIcon skull = SkullIcon.BH_BROWN1;
|
||||||
|
if (value >= DEEP_WILD_DROP_RISK_THRESHOLD) {
|
||||||
|
skull = SkullIcon.RED;
|
||||||
|
}
|
||||||
|
setSkullIcon(skull.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeDWSkullIcon() {
|
||||||
|
setSkullIcon(skulled ? 0 : -1);
|
||||||
|
ContainerListener listener = player.getAttribute("deepwild-value-listener");
|
||||||
|
if (listener != null) {
|
||||||
|
player.getInventory().getListeners().remove(listener);
|
||||||
|
player.getEquipment().getListeners().remove(listener);
|
||||||
|
}
|
||||||
|
player.removeAttribute("deepwild-value-listener");
|
||||||
|
player.removeAttribute("deepwild-value-risk");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the skulled.
|
* Sets the skulled.
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,14 @@ public class Item extends Node{
|
||||||
return value * getAmount();
|
return value * getAmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getAlchemyValue() {
|
||||||
|
long value = 1;
|
||||||
|
if (definition.getAlchemyValue(true) > value) {
|
||||||
|
value = definition.getAlchemyValue(true);
|
||||||
|
}
|
||||||
|
return value * getAmount();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a copy of the item.
|
* Gets a copy of the item.
|
||||||
* @return The item copy.
|
* @return The item copy.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
package core.game.world.map.zone.impl;
|
package core.game.world.map.zone.impl;
|
||||||
|
|
||||||
|
import content.global.handlers.item.equipment.brawling_gloves.BrawlingGloves;
|
||||||
|
import content.global.skill.summoning.familiar.Familiar;
|
||||||
import content.region.wilderness.handlers.DeepWildyThreat;
|
import content.region.wilderness.handlers.DeepWildyThreat;
|
||||||
import core.game.component.Component;
|
import core.game.component.Component;
|
||||||
import core.game.interaction.Option;
|
import core.game.interaction.Option;
|
||||||
import content.global.handlers.item.equipment.brawling_gloves.BrawlingGloves;
|
|
||||||
import core.game.node.Node;
|
import core.game.node.Node;
|
||||||
import core.game.node.entity.Entity;
|
import core.game.node.entity.Entity;
|
||||||
import core.game.node.entity.combat.CombatStyle;
|
import core.game.node.entity.combat.CombatStyle;
|
||||||
|
|
@ -12,17 +13,17 @@ import core.game.node.entity.npc.agg.AggressiveBehavior;
|
||||||
import core.game.node.entity.npc.agg.AggressiveHandler;
|
import core.game.node.entity.npc.agg.AggressiveHandler;
|
||||||
import core.game.node.entity.player.Player;
|
import core.game.node.entity.player.Player;
|
||||||
import core.game.node.entity.player.info.Rights;
|
import core.game.node.entity.player.info.Rights;
|
||||||
import content.global.skill.summoning.familiar.Familiar;
|
import core.game.node.entity.player.link.SkullManager;
|
||||||
import core.game.node.item.GroundItemManager;
|
import core.game.node.item.GroundItemManager;
|
||||||
import core.game.node.item.Item;
|
import core.game.node.item.Item;
|
||||||
|
import core.game.world.GameWorld;
|
||||||
import core.game.world.map.Location;
|
import core.game.world.map.Location;
|
||||||
import core.game.world.map.zone.MapZone;
|
import core.game.world.map.zone.MapZone;
|
||||||
import core.game.world.map.zone.ZoneBorders;
|
import core.game.world.map.zone.ZoneBorders;
|
||||||
import core.game.world.map.zone.ZoneRestriction;
|
import core.game.world.map.zone.ZoneRestriction;
|
||||||
|
import core.game.world.repository.Repository;
|
||||||
import core.tools.RandomFunction;
|
import core.tools.RandomFunction;
|
||||||
import org.rs09.consts.NPCs;
|
import org.rs09.consts.NPCs;
|
||||||
import core.game.world.GameWorld;
|
|
||||||
import core.game.world.repository.Repository;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,7 +95,9 @@ public final class WildernessZone extends MapZone {
|
||||||
if (!(killer instanceof Player)) return;
|
if (!(killer instanceof Player)) return;
|
||||||
|
|
||||||
boolean isDeepWildy = ((Player) killer).getSkullManager().isDeepWilderness();
|
boolean isDeepWildy = ((Player) killer).getSkullManager().isDeepWilderness();
|
||||||
boolean isValidTarget = e instanceof NPC && (isDeepWildy || e.asNpc().getName().contains("Revenant") || e.getId() == NPCs.CHAOS_ELEMENTAL_3200);
|
boolean isRevOrCele = e.asNpc().getName().contains("Revenant") || e.getId() == NPCs.CHAOS_ELEMENTAL_3200;
|
||||||
|
boolean isSufficientRisk = ((Player) killer).getAttribute("deepwild-value-risk", 0L) > SkullManager.DEEP_WILD_DROP_RISK_THRESHOLD;
|
||||||
|
boolean isValidTarget = e instanceof NPC && ((isDeepWildy && isSufficientRisk) || isRevOrCele);
|
||||||
|
|
||||||
if (isDeepWildy) {
|
if (isDeepWildy) {
|
||||||
DeepWildyThreat.adjustThreat((Player) killer, 50);
|
DeepWildyThreat.adjustThreat((Player) killer, 50);
|
||||||
|
|
@ -102,13 +105,13 @@ public final class WildernessZone extends MapZone {
|
||||||
|
|
||||||
if (!isValidTarget) return;
|
if (!isValidTarget) return;
|
||||||
|
|
||||||
int cEleGloveRate = isDeepWildy ? 50 : 150;
|
|
||||||
int normalGloveRate = isDeepWildy ? 100 : 150;
|
|
||||||
|
|
||||||
int pvpGearRate = getNewDropRate(e.asNpc().getDefinition().getCombatLevel());
|
int pvpGearRate = getNewDropRate(e.asNpc().getDefinition().getCombatLevel());
|
||||||
if (isDeepWildy)
|
if (isDeepWildy && isRevOrCele)
|
||||||
pvpGearRate /= 2;
|
pvpGearRate /= 2;
|
||||||
|
|
||||||
|
int cEleGloveRate = isDeepWildy ? 50 : 150;
|
||||||
|
int normalGloveRate = isDeepWildy && isRevOrCele ? 100 : (int)((1.0/(1.0-Math.pow(1.0 - (1.0/(double)pvpGearRate), 16.0))) * 5.0 / 6.0);
|
||||||
|
|
||||||
if (RandomFunction.roll(e.getId() == NPCs.CHAOS_ELEMENTAL_3200 ? cEleGloveRate : normalGloveRate)) {
|
if (RandomFunction.roll(e.getId() == NPCs.CHAOS_ELEMENTAL_3200 ? cEleGloveRate : normalGloveRate)) {
|
||||||
byte glove = (byte) RandomFunction.random(1, 13);
|
byte glove = (byte) RandomFunction.random(1, 13);
|
||||||
Item reward = new Item(BrawlingGloves.forIndicator(glove).getId());
|
Item reward = new Item(BrawlingGloves.forIndicator(glove).getId());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue