You can just give them a saving pen and let them save whenever they like. If you do this, though, you'd also want an onunacquire save with an int check to make sure it only fires every second or so (to avoid multiple saves when a bag is dropped, for example). This is the combination we finally settled on, since anything with ExportAll causes a large number of problems (bartering, for example, and shifted characters, being another that comes prominently to mind. Allowing player-controlled saves is far and away the most popular option with players, but it opens up dupe exploits without the aforementioned unacquire. We also save on rest, because players inevitably forget to use the saving pen.
Here's the saving pen code, which I believe originally came from a Vault script, but is now basically all original code:
#include "fky_chat_const"
#include "x2_inc_switches"
#include "hg_inc"
#include "hg_hardcore_inc"
//new functions added as part of shifter-friendly save
//Tells whether the PC is a shifted shifter or druid - returns TRUE if they are shifted
int GetIsShifted(object oPC) {
effect eEffect;
int nShifted = FALSE;
if (GetLevelByclass(class_TYPE_SHIFTER, oPC) > 0 || GetLevelByclass(class_TYPE_DRUID, oPC) > 0) {
eEffect = GetFirstEffect(oPC);
while (GetIsEffectValid(eEffect)) {
if (GetEffectType(eEffect) == EFFECT_TYPE_POLYMORPH) {
nShifted = TRUE;
break;
}
eEffect = GetNextEffect(oPC);
}
}
return nShifted;
}
//export a single character unless shifted
void SafeExportSingle(object oPlayer) {
if (GetArea(oPlayer) != OBJECT_INVALID && GetIsShifted(oPlayer) == FALSE) {
ExportSingleCharacter(oPlayer);
}
}
//export em all unless shifted, with a .5 second delay between each
void SafeExportAll() {
object oPlayer = GetFirstPC();
float fDelayPerPC = 0.0;
while (GetIsObjectValid(oPlayer)) {
DelayCommand(fDelayPerPC, SafeExportSingle(oPlayer));
fDelayPerPC = fDelayPerPC + 0.5;
oPlayer = GetNextPC();
}
}
void main() {
int nEvent = GetUserDefinedItemEventNumber();
object oPC;
object oItem;
string sName, sPCName;
int nResult = X2_EXECUTE_SCRIPT_END;
if (nEvent == X2_ITEM_EVENT_ACTIVATE) {
oPC = GetItemActivator();
oItem = GetItemActivated();
ExportSingleCharacter(oPC);
if (GetIsHardcore(oPC))
UpdateHardcoreStats(oPC);
SendMessageToPC(oPC, "Your character was saved.");
sName = GetName(oPC);
sPCName = GetPCPlayerName(oPC);
WriteTimestampedLogEntry("Saving Pen used by player: " + sPCName + ", character: " + sName + ".");
SendMessageToAllDMs(COLOR_LT_BLUE2 + "Saving Pen used by player: " + sPCName + ", character: " + sName + "." +
COLOR_END);
}
SetExecutedScriptReturnValue(nResult);
}
Obviously not all will apply to your server, but that's the gist. Here's the unacquire code:
#include "hg_inc"
#include "playerhide_inc"
#include "x2_inc_itemprop"
#include "ac_itemreq_inc"
#include "ac_itemprop_inc"
#include "fky_chat_const"
#include "hg_hardcore_inc"
void main() {
object oItem = GetModuleItemLost();
object oLoser = GetModuleItemLostBy();
object oArea = GetArea(oItem);
object oPCArea = GetArea(oLoser);
string sResRef = GetResRef(oItem);
int nItType = GetBaseItemType(oItem);
object oPossessor = GetItemPossessor(oItem);
if (GetLocalInt(oLoser, "DebugItemEvents")) {
if (!GetIsObjectValid(oPossessor))
oPossessor = GetArea(oItem);
SendMessageToPC(oLoser, COLOR_WHITE + "Item Event: Unacquire" +
", Loser: " + GetName(oLoser) + " (" + GetObjectString(oLoser) +
"), Possessor: " + GetName(oPossessor) + " (" + GetObjectString(oPossessor) +
"), Item: " + GetName(oItem) + " (" + GetObjectString(oItem) +
") [x" + IntToString(GetItemStackSize(oItem)) + "]" + COLOR_END);
}
/* the object/area/possessor valid checks here are to ensure that
* barter doesn't break with onunacquire saving */
if (nItType != BASE_ITEM_POTIONS &&
nItType != BASE_ITEM_ENCHANTED_POTION &&
(!GetIsObjectValid(oItem) ||
GetIsObjectValid(oArea) ||
GetIsObjectValid(GetItemPossessor(oItem)))) {
ForceDelayedSave(oLoser);
}
/* covers all the epic spells */
string sRes3 = GetStringLeft(sResRef, 3);
if (FindSubString(" es_ ps_ qc_ aa_ ca_ fa_ ra_ sa_ ", " " + sRes3 + " ") >= 0 ||
sResRef == "waterbreath" ||
sResRef == "ringoffirewalk" ||
sResRef == "ringofpasswall" ||
sResRef == "ringoflevitation") {
if (sResRef != "ca_brd_stillsnd") {
DestroyObject(oItem);
FloatingTextStringOnCreature("Forcing undroppable items out of inventory destroys them.", oLoser, FALSE);
}
}
/* make sure bind-on-use items can't be transferred in bags */
if (GetItemCursedFlag(oItem) && sResRef == "bountifulbeaker") {
DestroyObject(oItem);
FloatingTextStringOnCreature("Forcing undroppable items out of inventory destroys them.", oLoser, FALSE);
}
if (GetItemIsStackable(oItem) && GetItemHasItemProperty(oItem, ITEM_PROPERTY_INVIS_ADDITIONAL)) {
itemproperty ip;
for (ip = GetFirstItemProperty(oItem); GetIsItemPropertyValid(ip); ip = GetNextItemProperty(oItem)) {
if (GetItemPropertyType(ip) == ITEM_PROPERTY_INVIS_ADDITIONAL &&
GetItemPropertyCostTableValue(ip) == IP_CONST_ADDITIONAL_UNDROPPABLE) {
DestroyObject(oItem);
FloatingTextStringOnCreature("Forcing undroppable items out of inventory destroys them.", oLoser, FALSE);
break;
}
}
}
/* ---- 'INVISIBLE' HELMS ---- //scripts in onequip, onunequip, onunacquire */
if (nItType == BASE_ITEM_HELMET) {
if (oItem == GetLocalObject(oLoser, "LastHelm")) {
object oHide = GetItemInSlot(INVENTORY_SLOT_CARMOUR, oLoser);
if (!GetIsObjectValid(oHide))
oHide = GetItemPossessedBy(oLoser, "playerhide");
if (GetIsObjectValid(oHide))
RemoveInvisibleHelmProperties(oHide);
DeleteLocalObject(oLoser, "LastHelm");
DeleteLocalString(oLoser, "LastHelm");
SetLocalInt(oLoser, "MidPlayerHideVerification", 1);
DelayCommand(0.1, VerifyPlayerHide(oLoser));
}
}
RemoveItemPropertiesOfDuration(DURATION_TYPE_TEMPORARY, oItem, -(ITEM_PROPERTY_LIGHT + 1));
DoHardCoreUnacquireTag(oLoser, oPossessor, oItem, oArea, oPCArea);
}
And here's the critical ForceDelayedSave function:
void ForceDelayedSave (object oPC, float fDelay=2.0) {
if (!GetIsPC(oPC) || GetIsDM(oPC))
return;
int nUptime = GetLocalInt(GetModule(), "uptime");
int nDelayedSave = GetLocalInt(oPC, "DelayedSave");
if (nDelayedSave < nUptime) {
SetLocalInt(oPC, "DelayedSave", nUptime);
DelayCommand(fDelay, ForceSave(oPC, TRUE));
} else if (nDelayedSave == nUptime + 1) {
SetLocalInt(oPC, "DelayedSave", nUptime + 2);
DelayCommand(10.0, ForceSave(oPC));
}
}
The uptime var is just incremented by 6 every module heartbeat (well, really we retrieve the Unix timestamp for greater accuracy, since lin servers tend to fire heartbeats around every 5.2 seconds, but as with horseshoes, hand grenades, and nuclear warfare, close is good enough here).
void main() {
object oMod = GetModule();
object oMes = GetMessenger();
int nUptime = GetLocalInt(oMod, "uptime");
int nMemory = GetProcessMemoryUsage();
int nMessages = 0, nPlayers = 0;
string sServer = GetLocalString(oMod, "ServerNumber");
string sBootTime = IntToString(GetLocalInt(oMod, "boottime"));
{
object oPC;
for (oPC = GetFirstPC(); GetIsObjectValid(oPC); oPC = GetNextPC()) {
nPlayers++;
RecalculateMovementRate(oPC);
RecalculateDexModifier(oPC);
int nAlarm = GetLocalInt(oPC, "AlarmUptime");
if (nAlarm > 0 && nAlarm <= nUptime) {
DeleteLocalInt(oPC, "AlarmUptime");
SendChatLogMessage(oPC, C_PINK + "[Alarm] " + GetLocalString(oPC, "AlarmMessage") + C_END, oMes, 4);
}
}
SetLocalInt(oMod, "ServerPlayers", nPlayers);
}
SQLExecDirect("SELECT UNIX_TIMESTAMP() - " + sBootTime + ", UTC_TIMESTAMP(), UNIX_TIMESTAMP(), " +
"COUNT(*) FROM user_messages WHERE um_recipient = '*" + sServer + "'");
if (SQLFetch() == SQL_SUCCESS) {
nUptime = StringToInt(SQLGetData(1));
nMessages = StringToInt(SQLGetData(4));
SetLocalInt(oMod, "uptime", nUptime);
SetLocalInt(oMod, "realtime", StringToInt(SQLGetData(3)));
SetLocalString(oMod, "utctime", SQLGetData(2));
*snip*
Obviously, no solution is perfect, at least in this case - it depends heavily on what your players want, and how complex you're willing to make the code. Sorry for the codebomb nature of the above - lmk if you have questions.
Funky