#W# Rentable Inn rooms!
This commit is contained in:
parent
3cd1cef779
commit
7104f56ebf
8 changed files with 579 additions and 7 deletions
|
|
@ -111,7 +111,7 @@ namespace Server.Scripts.Commands
|
|||
Console.WriteLine( "Spawning Dungeon Chests..." );
|
||||
Server.SpawnGenerator.Parse( m, "chests.map" );
|
||||
|
||||
if ( Server.Settings.S_DungeonTraps )
|
||||
if ( Server.Settings.S_DungeonFloorTraps )
|
||||
{
|
||||
Console.WriteLine( "Spawining Dungeon Floor Traps..." );
|
||||
Server.SpawnGenerator.Parse( m, "traps.map" );
|
||||
|
|
|
|||
140
Scripts/Commands/GenInnDoors.cs
Normal file
140
Scripts/Commands/GenInnDoors.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Collections;
|
||||
using Server;
|
||||
using Server.Items;
|
||||
using Server.Custom.InnRooms; // Ensure it can find our InnDoor!
|
||||
|
||||
namespace Server.Commands
|
||||
{
|
||||
public class GenInnDoors
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("InnGen", AccessLevel.Administrator, new CommandEventHandler(GenInnDoors_OnCommand));
|
||||
}
|
||||
|
||||
[Usage("InnGen")]
|
||||
[Description("Generates Inn Doors from a configuration file.")]
|
||||
public static void GenInnDoors_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
e.Mobile.SendMessage("Generating Inn Doors from config, please wait.");
|
||||
|
||||
int count = new InnDoorCreator().CreateDoors();
|
||||
|
||||
e.Mobile.SendMessage("Inn Door generation complete. {0} doors were generated.", count);
|
||||
}
|
||||
|
||||
public class InnDoorCreator
|
||||
{
|
||||
private int m_Count;
|
||||
private static Queue m_Queue = new Queue();
|
||||
|
||||
// Finds and deletes existing InnDoors at the target location to prevent stacking
|
||||
public static bool FindExistingDoor(Map map, Point3D p)
|
||||
{
|
||||
IPooledEnumerable eable = map.GetItemsInRange(p, 0);
|
||||
|
||||
foreach (Item item in eable)
|
||||
{
|
||||
if (item is InnDoor)
|
||||
{
|
||||
int delta = item.Z - p.Z;
|
||||
|
||||
if (delta >= -12 && delta <= 12) // Uses your existing Z-delta logic
|
||||
m_Queue.Enqueue(item);
|
||||
}
|
||||
}
|
||||
|
||||
eable.Free();
|
||||
|
||||
while (m_Queue.Count > 0)
|
||||
((Item)m_Queue.Dequeue()).Delete();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CreateDoor(Point3D loc, Map mapLoc, Point3D roomLoc, Map roomMap, int itemID)
|
||||
{
|
||||
if (!FindExistingDoor(mapLoc, loc))
|
||||
{
|
||||
m_Count++;
|
||||
|
||||
// Uses our new parameterized constructor for custom facings!
|
||||
InnDoor door = new InnDoor(itemID);
|
||||
|
||||
// Wire up the void room coordinates automatically
|
||||
door.RoomLocation = roomLoc;
|
||||
door.RoomMap = roomMap;
|
||||
|
||||
door.MoveToWorld(loc, mapLoc);
|
||||
}
|
||||
}
|
||||
|
||||
public int CreateDoors()
|
||||
{
|
||||
// Reads from the same Decoration directory[cite: 3]
|
||||
string filePath = Path.Combine(Core.BaseDirectory, "Data", "Decoration", "inndoors.cfg");
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Console.WriteLine("Warning: {0} not found. No Inn Doors generated.", filePath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
using (StreamReader ip = new StreamReader(filePath))
|
||||
{
|
||||
string line;
|
||||
while ((line = ip.ReadLine()) != null)
|
||||
{
|
||||
line = line.Trim();
|
||||
|
||||
if (line.Length == 0 || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
string[] split = line.Split(new char[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// We need at least 8 arguments (Door Map/X/Y/Z and Room Map/X/Y/Z)
|
||||
if (split.Length >= 8)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map mapLoc = Map.Parse(split[0]);
|
||||
int xLoc = int.Parse(split[1]);
|
||||
int yLoc = int.Parse(split[2]);
|
||||
int zLoc = int.Parse(split[3]);
|
||||
|
||||
Map mapRoom = Map.Parse(split[4]);
|
||||
int xRoom = int.Parse(split[5]);
|
||||
int yRoom = int.Parse(split[6]);
|
||||
int zRoom = int.Parse(split[7]);
|
||||
|
||||
// Optional 9th argument: ItemID for custom facings! Defaults to 0x6E5 (1765) if missing.
|
||||
int itemID = 1765;
|
||||
if (split.Length >= 9)
|
||||
{
|
||||
// Handles both hex (0x6F3) and integer (1779) formats
|
||||
if (split[8].StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
itemID = Convert.ToInt32(split[8], 16);
|
||||
else
|
||||
itemID = int.Parse(split[8]);
|
||||
}
|
||||
|
||||
if (mapLoc != null && mapRoom != null)
|
||||
{
|
||||
CreateDoor(new Point3D(xLoc, yLoc, zLoc), mapLoc, new Point3D(xRoom, yRoom, zRoom), mapRoom, itemID);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error parsing Inn Door config line: {0}\n{1}", line, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m_Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
352
Scripts/Engines/InnDoor.cs
Normal file
352
Scripts/Engines/InnDoor.cs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Custom.InnRooms;
|
||||
using Server.Multis;
|
||||
using System.Collections.Generic;
|
||||
using Server.ContextMenus;
|
||||
|
||||
namespace Server.Custom.InnRooms
|
||||
{
|
||||
public class InnDoor : Item
|
||||
{
|
||||
private Mobile m_Renter;
|
||||
private InnRoomHouse m_House;
|
||||
private DateTime m_RentExpires;
|
||||
|
||||
// Where is the void room located?
|
||||
private Point3D m_RoomLocation;
|
||||
private Map m_RoomMap;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool ForceEviction
|
||||
{ get { return false; } set { if (value == true) { EvictTenant(); } } }
|
||||
|
||||
// Configuration
|
||||
private int m_RentCost = 5000; // 5,000 gold per week
|
||||
private TimeSpan m_RentDuration = TimeSpan.FromDays(7); // 1 week
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Mobile Renter { get { return m_Renter; } set { m_Renter = value; InvalidateProperties(); } }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Point3D RoomLocation { get { return m_RoomLocation; } set { m_RoomLocation = value; } }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Map RoomMap { get { return m_RoomMap; } set { m_RoomMap = value; } }
|
||||
|
||||
[Constructable]
|
||||
public InnDoor() : this(0x6E5)
|
||||
{
|
||||
}
|
||||
|
||||
[Constructable]
|
||||
public InnDoor(int itemID) : base(itemID) // Standard wooden door graphic
|
||||
{
|
||||
Movable = false;
|
||||
Name = "A Vacant Inn Room";
|
||||
}
|
||||
|
||||
public InnDoor(Serial serial) : base(serial)
|
||||
{
|
||||
}
|
||||
|
||||
public override void OnDoubleClick(Mobile from)
|
||||
{
|
||||
if (!from.InRange(GetWorldLocation(), 2))
|
||||
{
|
||||
from.LocalOverheadMessage(Server.Network.MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
|
||||
return;
|
||||
}
|
||||
|
||||
// SCENARIO A: The room is VACANT
|
||||
if (m_Renter == null)
|
||||
{
|
||||
if (RoomLocation == Point3D.Zero || RoomMap == null)
|
||||
{
|
||||
from.SendMessage(33, "This door is out of order (No Room Linked).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (from.Backpack != null && from.Backpack.ConsumeTotal(typeof(Gold), m_RentCost))
|
||||
{
|
||||
// Rent the room!
|
||||
m_Renter = from;
|
||||
m_RentExpires = DateTime.UtcNow + m_RentDuration;
|
||||
Name = from.Name + "'s Inn Room";
|
||||
|
||||
// Spawn the invisible house controller in the void room
|
||||
m_House = new InnRoomHouse(from);
|
||||
//m_House.MoveToWorld(RoomLocation, RoomMap);
|
||||
// BURY THE STAIRCASE! We spawn the house 20 Z-levels beneath the actual floor.
|
||||
Point3D buriedLocation = new Point3D(RoomLocation.X, RoomLocation.Y, RoomLocation.Z - 20);
|
||||
m_House.MoveToWorld(buriedLocation, RoomMap);
|
||||
|
||||
from.PlaySound(0x249);
|
||||
from.SendMessage(68, $"You have rented this room for {m_RentDuration.Days} days.");
|
||||
TeleportToRoom(from);
|
||||
}
|
||||
else
|
||||
{
|
||||
from.SendMessage(33, $"You need {m_RentCost} gold in your backpack to rent this room.");
|
||||
}
|
||||
}
|
||||
// SCENARIO B: The room is RENTED
|
||||
else
|
||||
{
|
||||
// Check if the rent has expired
|
||||
if (DateTime.UtcNow > m_RentExpires)
|
||||
{
|
||||
EvictTenant();
|
||||
from.SendMessage(33, "The rent expired and the room has been cleared. It is now vacant.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Access using your BaseHouse logic!
|
||||
if (m_House != null && (m_House.IsOwner(from) || m_House.IsFriend(from) || m_House.HasAccess(from)))
|
||||
{
|
||||
TeleportToRoom(from);
|
||||
}
|
||||
else
|
||||
{
|
||||
from.SendMessage(33, "The door is locked and you do not have permission to enter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TeleportToRoom(Mobile m)
|
||||
{
|
||||
m.PlaySound(0x1EA);
|
||||
m.MoveToWorld(RoomLocation, RoomMap);
|
||||
m.SendMessage(0x3B2, "You enter the inn room.");
|
||||
}
|
||||
|
||||
public void EvictTenant()
|
||||
{
|
||||
if (m_Renter != null && m_House != null && !m_House.Deleted)
|
||||
{
|
||||
// 1. Locate the player's bank box
|
||||
BankBox bank = m_Renter.BankBox;
|
||||
|
||||
if (bank != null)
|
||||
{
|
||||
// 2. Spawn our heavy-duty moving crate
|
||||
InnEvictionCrate crate = new InnEvictionCrate();
|
||||
|
||||
// 3. Pack up all Secured Containers
|
||||
// We must iterate backward (Count - 1 to 0) when removing things from lists!
|
||||
for (int i = m_House.Secures.Count - 1; i >= 0; --i)
|
||||
{
|
||||
SecureInfo info = m_House.Secures[i] as SecureInfo;
|
||||
if (info != null && info.Item != null)
|
||||
{
|
||||
Container secureContainer = info.Item;
|
||||
secureContainer.IsSecure = false;
|
||||
secureContainer.IsLockedDown = false;
|
||||
secureContainer.Movable = true;
|
||||
|
||||
// Drop the entire secured bag/chest into the crate
|
||||
crate.DropItem(secureContainer);
|
||||
}
|
||||
}
|
||||
m_House.Secures.Clear();
|
||||
|
||||
// 4. Pack up all individual Locked Down Items
|
||||
for (int i = m_House.LockDowns.Count - 1; i >= 0; --i)
|
||||
{
|
||||
Item item = m_House.LockDowns[i] as Item;
|
||||
if (item != null)
|
||||
{
|
||||
item.IsLockedDown = false;
|
||||
item.Movable = true;
|
||||
|
||||
// Drop the loose item into the crate
|
||||
crate.DropItem(item);
|
||||
}
|
||||
}
|
||||
m_House.LockDowns.Clear();
|
||||
|
||||
// 5. Deliver the crate to the bank!
|
||||
if (crate.Items.Count > 0)
|
||||
{
|
||||
// Using DropItem forces it into the bank, bypassing standard bank limits
|
||||
bank.DropItem(crate);
|
||||
m_Renter.SendMessage(33, "Your inn room rent has expired. Your belongings have been packed into a crate and placed in your bank box.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// If they didn't have any items in the room, just delete the empty crate
|
||||
crate.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Demolish the invisible house controller
|
||||
m_House.Delete();
|
||||
}
|
||||
|
||||
// 7. Reset the door so a new player can rent it
|
||||
m_Renter = null;
|
||||
m_House = null;
|
||||
Name = "A Vacant Inn Room";
|
||||
}
|
||||
|
||||
public override void GetProperties(ObjectPropertyList list)
|
||||
{
|
||||
base.GetProperties(list);
|
||||
|
||||
if (m_Renter != null)
|
||||
{
|
||||
list.Add(1060658, "Rent Cost\tPaid"); // ~1_val~: ~2_val~
|
||||
list.Add(1060659, "Expires\t{0}", m_RentExpires.ToString("g")); // ~1_val~: ~2_val~
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(1060658, "Rent Cost\t{0} Gold", m_RentCost); // ~1_val~: ~2_val~
|
||||
}
|
||||
}
|
||||
|
||||
public override void Serialize(GenericWriter writer)
|
||||
{
|
||||
base.Serialize(writer);
|
||||
writer.Write((int)0); // version
|
||||
|
||||
writer.Write(m_Renter);
|
||||
writer.WriteItem(m_House);
|
||||
writer.Write(m_RentExpires);
|
||||
writer.Write(m_RoomLocation);
|
||||
writer.Write(m_RoomMap);
|
||||
}
|
||||
|
||||
public override void Deserialize(GenericReader reader)
|
||||
{
|
||||
base.Deserialize(reader);
|
||||
int version = reader.ReadInt();
|
||||
|
||||
m_Renter = reader.ReadMobile();
|
||||
m_House = reader.ReadItem() as InnRoomHouse;
|
||||
m_RentExpires = reader.ReadDateTime();
|
||||
m_RoomLocation = reader.ReadPoint3D();
|
||||
m_RoomMap = reader.ReadMap();
|
||||
}
|
||||
// --- 1. THE CONTEXT MENU MENU OVERRIDE ---
|
||||
public override void GetContextMenuEntries(Mobile from, List<ContextMenuEntry> list)
|
||||
{
|
||||
base.GetContextMenuEntries(from, list);
|
||||
|
||||
// Only the active renter gets the option to renew
|
||||
if (from.Alive && m_Renter == from)
|
||||
{
|
||||
// 1062400 is the UO Client Cliloc for "Renew Contract"
|
||||
list.Add(new RenewRentEntry(from, this));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. THE CONTEXT MENU ACTION LOGIC ---
|
||||
private class RenewRentEntry : ContextMenuEntry
|
||||
{
|
||||
private Mobile m_From;
|
||||
private InnDoor m_Door;
|
||||
|
||||
// The '2' is the interaction range
|
||||
public RenewRentEntry(Mobile from, InnDoor door) : base(1034309, 2)
|
||||
{
|
||||
m_From = from;
|
||||
m_Door = door;
|
||||
}
|
||||
|
||||
public override void OnClick()
|
||||
{
|
||||
if (m_Door.Deleted || m_Door.Renter != m_From)
|
||||
return;
|
||||
|
||||
// Check their backpack for the gold
|
||||
if (m_From.Backpack != null && m_From.Backpack.ConsumeTotal(typeof(Gold), m_Door.m_RentCost))
|
||||
{
|
||||
// Add 7 days to their current expiration time
|
||||
m_Door.m_RentExpires += m_Door.m_RentDuration;
|
||||
|
||||
m_From.PlaySound(0x249); // Coin jingle sound
|
||||
m_From.SendMessage(68, $"You have paid {m_Door.m_RentCost} gold to extend your rent.");
|
||||
m_From.SendMessage(68, $"Your room is now paid until: {m_Door.m_RentExpires.ToString("g")}");
|
||||
m_Door.InvalidateProperties(); // Updates the door's mouse-over text
|
||||
}
|
||||
else
|
||||
{
|
||||
m_From.SendMessage(33, $"You need {m_Door.m_RentCost} gold in your backpack to renew this room.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. BONUS: DRAG AND DROP RENEWAL ---
|
||||
public override bool OnDragDrop(Mobile from, Item dropped)
|
||||
{
|
||||
// If the renter drops gold directly onto the door...
|
||||
if (from == m_Renter && dropped is Gold)
|
||||
{
|
||||
if (dropped.Amount == m_RentCost)
|
||||
{
|
||||
dropped.Delete(); // Consume the gold
|
||||
m_RentExpires += m_RentDuration;
|
||||
|
||||
from.PlaySound(0x249);
|
||||
from.SendMessage(68, $"You hand the gold to the landlord and extend your rent.");
|
||||
from.SendMessage(68, $"Your room is now paid until: {m_RentExpires.ToString("g")}");
|
||||
InvalidateProperties();
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
from.SendMessage(33, $"You must drop exactly {m_RentCost} gold on the door to renew it.");
|
||||
return false; // Rejects the drop and bounces the gold back to their cursor
|
||||
}
|
||||
}
|
||||
|
||||
return base.OnDragDrop(from, dropped);
|
||||
}
|
||||
}
|
||||
|
||||
public class InnEvictionCrate : WoodenBox
|
||||
{
|
||||
public override int DefaultMaxItems { get { return 500; } }
|
||||
public override int DefaultMaxWeight { get { return 5000; } }
|
||||
|
||||
[Constructable]
|
||||
public InnEvictionCrate()
|
||||
{
|
||||
Name = "Evicted Inn Room Belongings";
|
||||
Hue = 33; // Bright red
|
||||
}
|
||||
|
||||
public InnEvictionCrate(Serial serial) : base(serial) { }
|
||||
|
||||
// 1. This prevents players from putting anything INTO the crate
|
||||
public override bool CheckHold(Mobile m, Item item, bool message, bool checkItems, int plusItems, int plusWeight)
|
||||
{
|
||||
if (m.AccessLevel < AccessLevel.GameMaster)
|
||||
{
|
||||
if (message)
|
||||
m.SendLocalizedMessage(1061145); // "You cannot place items into a house moving crate."
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.CheckHold(m, item, message, checkItems, plusItems, plusWeight);
|
||||
}
|
||||
|
||||
// 2. This makes the crate automatically vanish once the player empties it
|
||||
public override void OnItemRemoved(Item item)
|
||||
{
|
||||
base.OnItemRemoved(item);
|
||||
|
||||
if (this.TotalItems == 0)
|
||||
{
|
||||
Delete();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Serialize(GenericWriter writer) { base.Serialize(writer); writer.Write((int)0); }
|
||||
public override void Deserialize(GenericReader reader) { base.Deserialize(reader); int version = reader.ReadInt(); }
|
||||
}
|
||||
}
|
||||
79
Scripts/Engines/InnRoomHouse.cs
Normal file
79
Scripts/Engines/InnRoomHouse.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom.InnRooms
|
||||
{
|
||||
public class InnRoomHouse : BaseHouse
|
||||
{
|
||||
public override Rectangle2D[] Area
|
||||
{
|
||||
get { return new Rectangle2D[] { new Rectangle2D(-5, -5, 10, 10) }; }
|
||||
}
|
||||
|
||||
public override Point3D BaseBanLocation
|
||||
{
|
||||
get { return new Point3D(this.X, this.Y - 5, this.Z + 20); }
|
||||
}
|
||||
|
||||
public InnRoomHouse(Mobile owner) : base(0x1DF3, owner, 50, 2)
|
||||
{
|
||||
RestrictDecay = true;
|
||||
}
|
||||
|
||||
public InnRoomHouse(Serial serial) : base(serial)
|
||||
{
|
||||
}
|
||||
|
||||
// --- THE LOCKDOWN FIX ---
|
||||
public override int GetAosMaxLockdowns()
|
||||
{
|
||||
return 50; // 50 floor items
|
||||
}
|
||||
|
||||
public override int GetAosMaxSecures()
|
||||
{
|
||||
return 250; // 250 total items in the room
|
||||
}
|
||||
// ------------------------
|
||||
|
||||
public override bool IsInside(Point3D p, int height)
|
||||
{
|
||||
if (Deleted)
|
||||
return false;
|
||||
|
||||
int rx = p.X - this.X;
|
||||
int ry = p.Y - this.Y;
|
||||
|
||||
bool inArea = false;
|
||||
foreach (Rectangle2D rect in Area)
|
||||
{
|
||||
if (rect.Contains(new Point2D(rx, ry)))
|
||||
{
|
||||
inArea = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inArea)
|
||||
return false;
|
||||
|
||||
if (p.Z >= this.Z && (p.Z + height) <= this.Z + 40)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void Serialize(GenericWriter writer)
|
||||
{
|
||||
base.Serialize(writer);
|
||||
writer.Write((int)0);
|
||||
}
|
||||
|
||||
public override void Deserialize(GenericReader reader)
|
||||
{
|
||||
base.Deserialize(reader);
|
||||
int version = reader.ReadInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ namespace Server
|
|||
public static int S_MaxGold = 1000; // maximum is 10,000
|
||||
public static bool S_CanStealWhileHoldingThings = true;
|
||||
public static bool S_MonstersSurprise = true;
|
||||
public static bool S_DungeonTraps = false;
|
||||
public static bool S_DungeonFloorTraps = false;
|
||||
public static double S_FarmSpawnTimer = 15.0;
|
||||
public static int S_HarvestRange = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ namespace Server.Scripts.Custom
|
|||
{
|
||||
Console.WriteLine("TasksManager: Running scheduled hourly tasks...");
|
||||
|
||||
// Task 1: Wipe all floor traps so spawners can place new ones
|
||||
ClearSpawnedTraps();
|
||||
if ( Settings.S_DungeonFloorTraps )
|
||||
{
|
||||
ClearSpawnedTraps();
|
||||
}
|
||||
|
||||
// FUTURE TASKS:
|
||||
// Just add new methods here as your server grows!
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue