Keeping what a player discovered
A character finds things as they play. The places they find - villages, towns, camps and other points of interest - show on the map with their names. The books they read are remembered, with how far they studied each. By default a server keeps none of it: it lasts as long as the visit, and a new visit starts with a map that shows the whole land and no names.
A game mode can keep it. It turns tracking on, the server records what each player’s game reports, and the mode saves
that record in its own database and puts it back at the next login - quietly, with no “discovered” notice. It is the way a
mode keeps a character’s stats and skills too, and it needs the mode’s
database. The names are in
the Discovery group of the server API. The examples
are Lua; a C# mode has the same calls on IServerApi.
1. What is kept
Section titled “1. What is kept”Each thing is a kind. The words are "places", "reading" and "ui", and the constants DISCOVERY_PLACES,
DISCOVERY_READING and DISCOVERY_UI are the same (listed here):
| Kind | What it holds | Kept |
|---|---|---|
places |
the named places (villages, towns, camps), the points of interest and the kinds of them | per level |
reading |
the books they read and how far they studied each | once for the whole game |
ui |
the map pin they placed and the legend categories they switched off | per level - no game reports it yet, see What to know |
Once a kind is tracked, a player’s game reports what it holds of it a few seconds after each spawn and again when it changes. The server keeps that as the player’s record, a union that only grows: every report is added to what the record holds, and nothing a report leaves out is taken away, so a late report or a half-finished restore can never make it smaller. What you save is data the server keeps for you - for each kind a text in a fixed, versioned layout, ready for a database column. Never edit it. A record saved today is still readable after a server update.
2. Turning it on
Section titled “2. Turning it on”SetDiscoveryTracking(kinds) names the kinds the players’ games report:
true for every kind, one name, or a list of names. nil, false and {} track nothing. Call it when the mode starts. It
answers false, and changes nothing, when a word is no kind.
function OnGameModeInit() SetDiscoveryTracking({ DISCOVERY_PLACES, DISCOVERY_READING })endNothing is tracked until the mode says so. With nothing tracked, no message is sent and no player’s game does any work. A
reload of the mode starts tracking from nothing again - the new mode sets its own - but the players’ records stay.
SetPlayerDiscoveryTracking(pid, kinds) gives one player a set of their
own: {} in OnPlayerConnect keeps a guest’s game from reporting anything, nil hands them back to the server-wide set.
GetDiscoveryTracking and
GetPlayerDiscoveryTracking read them back.
3. Saving a character
Section titled “3. Saving a character”GetPlayerDiscovery(pid) answers the player’s record:
local record = GetPlayerDiscovery(pid)-- record.level "klaster" - the level the blocks are of-- record.capable { "places", "reading" } - what this player's game can do (empty until its first report)-- record.places { format = 1, data = "<base64 text>", bytes = 612, updated = 1761994000 }-- record.reading { format = 1, data = "<base64 text>", bytes = 88, updated = 1761994120 }A kind with nothing reported or set is absent, so test for it (if record.places then). data is the text to save, up to about
64,000 characters, which fits a TEXT column. format is the version of its layout (1 today): save it with the data and give it
back as it was. bytes and updated (seconds since 1970) say how big the block is and when it last changed. A connected player
always has a record, empty until their game has reported or the mode has set something, so read it again after the game has
reported, not before.
When to save. OnPlayerDiscoveryChange(pid, kind, level) says a kind
grew, or that the player’s game reported it for the first time, and the record already holds the new state. It runs at most once
a kind every five seconds - a walk across the map is a few calls - and not for the mode’s own
SetPlayerDiscovery. level is empty for the reading. Save on a timer too, and
in OnPlayerDisconnect, where the record holds what the player found in their
last seconds. A change still waiting for its five seconds is told just before the goodbye, and before a reloaded or stopped
mode’s OnGameModeExit, so a mode that saves only what it was told misses nothing. Write only the blocks that differ from your
last save.
Never write before the restore has gone out. Until then the record holds only what this game reports - nothing before its
first report - and saving that over the stored character would lose it. So write only once the saved record was put back
(section 4); from then on the record is the saved one united with the game’s, and what you write is never less than what was
stored. A character with nothing saved yet has nothing to put back: write once
IsPlayerDiscoveryKnown(pid, kind) says its game has reported, so the
first save is the game’s own state.
The mode below keeps one row per character, level and kind, the books under an empty level. It keys a character by name for brevity (a mode with accounts uses its account’s id) and uses the database calls of the databases guide:
local db = GetDatabase()local KEPT = { DISCOVERY_PLACES, DISCOVERY_READING } -- the kinds this mode keeps-- pid -> { who = the character, ready = may be written, fresh = nothing saved yet, dirty = grew since the last save,-- stored = { ["kind|level"] = the text last written or read } }local players = {}
function OnGameModeInit() if not db then Log("no database: what players discover is not kept") return end db:ExecuteSync("CREATE TABLE IF NOT EXISTS player_discovery (who VARCHAR(24) NOT NULL, level VARCHAR(32) NOT NULL, " .. "kind VARCHAR(12) NOT NULL, format SMALLINT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (who, level, kind))") SetDiscoveryTracking(KEPT) SetTimer(saveAllDiscovery, 60000, true) -- every minute, for whoever found something sinceend
function OnPlayerConnect(pid) players[pid] = { stored = {} } restoreDiscovery(pid, GetPlayerName(pid):lower()) -- section 4; with passwords, once the password is rightend
function OnPlayerDiscoveryChange(pid, kind, level) if players[pid] then players[pid].dirty = true endend
function OnPlayerDisconnect(pid, reason) saveDiscovery(pid) -- what they found in their last seconds is in the record players[pid] = nilend
function OnGameModeExit() -- the server stops, or the mode is reloaded for pid in pairs(players) do saveDiscovery(pid) endendThe other half writes. The minute’s round writes the characters marked dirty, and the goodbye writes whoever leaves. A block
goes out only when its text differs from the one in stored, and a failed write puts everything back to be written again:
local function reported(pid) -- has their game reported any kind we keep? for _, kind in ipairs(KEPT) do if IsPlayerDiscoveryKnown(pid, kind) then return true end endend
function saveDiscovery(pid) local p = players[pid] if not (p and p.who) then return end if p.fresh and reported(pid) then p.ready = true end -- a new character: its game's own first report is the start local record = p.ready and GetPlayerDiscovery(pid) -- else nothing is written: the saved record is not back yet if not record then return end p.dirty = nil local batch = {} for _, kind in ipairs(KEPT) do local block = record[kind] local level = kind == DISCOVERY_READING and "" or record.level -- the books are the same on every level local key = kind .. "|" .. level if block and block.data ~= p.stored[key] then -- only what differs from the last save p.stored[key] = block.data batch[#batch + 1] = { "DELETE FROM player_discovery WHERE who = @w AND level = @l AND kind = @k", { w = p.who, l = level, k = kind } } batch[#batch + 1] = { "INSERT INTO player_discovery (who, level, kind, format, data) VALUES (@w, @l, @k, @f, @d)", { w = p.who, l = level, k = kind, f = block.format, d = block.data } } end end if #batch == 0 then return end db:Batch(batch, function(ok, err) if ok then return end Log("player_discovery not saved: " .. tostring(err)) p.stored, p.dirty = {}, true -- the next round writes it all again end)end
function saveAllDiscovery() for pid, p in pairs(players) do if p.dirty then saveDiscovery(pid) end endend4. Putting it back at the login
Section titled “4. Putting it back at the login”SetPlayerDiscovery(pid, record) takes a record as GetPlayerDiscovery gives
it - { level = "klaster", places = { format = 1, data = "..." }, reading = { ... } } - and the kinds it lacks are left alone.
The server takes it at once (GetPlayerDiscovery right afterwards answers it) and sends it to the player’s game, which adds
it to what it already holds. The game ends up with the saved discoveries and anything found since the player joined, and no
notice of a discovery shows. A restore made before the player is in the world waits for their spawn, so the login may come
first, and a game that reports before the restore lands is no trouble: the two are united.
It answers false for a player who is not there, or when a block was skipped - text that is no block of that kind, a format the
server does not know, a block over the size limit. The other blocks are applied all the same. A skipped block is usually a saved
row you cannot read, so the mode below writes nothing for that character this visit rather than replace it.
-- when the mode knows which character this is: OnPlayerConnect on a server without passwords, else once the password is rightfunction restoreDiscovery(pid, who) local p = players[pid] if not (db and p) then return end p.who = who local level = GetLevel():lower() db:Query("SELECT kind, level, format, data FROM player_discovery WHERE who = @w AND (level = @l OR level = '')", { w = who, l = level }, function(rows, err) if err then Log("player_discovery not read: " .. err) return end -- nothing is written this visit if players[pid] ~= p then return end -- they left meanwhile; the id may be someone else's now if #rows == 0 then p.fresh = true -- nothing saved: their game's own first report starts the record return end local record = { level = level } for _, row in ipairs(rows) do record[row.kind] = { format = row.format, data = row.data } p.stored[row.kind .. "|" .. row.level] = row.data end if SetPlayerDiscovery(pid, record) then p.ready, p.dirty = true, true -- the record is the saved one united with the game's: the next round writes what differs else Log("part of what " .. GetPlayerName(pid) .. " found could not be put back: nothing is saved this visit") end end)endThe player’s game answers every restore a little later, and
OnPlayerDiscoveryRestore(pid, kind, ok) tells the mode: ok is false
when the game could not do that kind. The record kept what the mode set either way, so the callback is for a log line or a retry
after the next spawn.
The example modes
Section titled “The example modes”basicrp.lua in the server folder is the worked example. Its DISCOVERY table has a rule for each kind it can keep, places and
reading (a book read once gives no experience again after a return), both off as shipped: no table is made, nobody’s game is
asked for anything and the map is the game’s own. Turn a rule on and BasicRP makes its player_discovery table by itself, asks
the players’ games for those kinds, saves what grew on the minute’s round and when the player leaves, and puts the rows back at
the next login. That is this page’s pattern, guards included: a read that fails or a row the server cannot read saves nothing
that visit. It does not keep the pin and the legend. freeroam.lua and duel_arena.lua keep nothing.
The table’s third rule, explorer, is about the game’s Explorer perk, which shows a character the whole map at once. With
explorer = "strip" BasicRP leaves that perk out of the perks it puts back at the login (it stays among the character’s saved
perks), so the map fills as the character finds places. "keep" is the default. The perk list has the
perk’s ID.
What to know
Section titled “What to know”- One game, several characters. A mode that lets one game play several characters in turn calls
ForgetPlayerDiscovery(pid, kinds)at each switch (every kind when left out) and then restores the next character’s record. Or it passestrueas the third argument ofSetPlayerDiscovery-replace- and the record becomes exactly the one given. The places cannot be forgotten yet: both skip them, do the other kinds and answerfalse, and a place that was found stays in the record and in the game. So such a mode keeps thereadingand leavesplacesout ofSetDiscoveryTracking. Otherwise the second character is saved with the first one’s places. - Places belong to a level, books to the character. A server runs one level
(
levelinserver.toml), and its record holds that level’s places and the books. A block of another level that the mode sets is kept in the record and never sent to the game.GetPlayerDiscovery(pid, level)reads another level’s record. The levels are listed here. - A player’s game may not be able to.
capablein the record lists what their game can do, and is empty until its first report. A build of the game that KCD:MP does not know reports nothing and applies nothing. A game that closed while it read or put back a kind leaves that kind alone on that PC until KCD:MP or the game is updated. A restore it cannot do is answeredfalse. The record keeps what you set, so saving it again changes nothing and nothing is lost. - The map pin and the legend are not reported yet. The server has a record and a saved format for
ui, but no player’s game reports it in this version andcapablenever lists it, so a mode that tracks it gets nothing. They are the player’s own marks, not discoveries: when a game does report them, the newest report replaces the record instead of growing it. - Story quests. The game’s own story quests sleep unless the mode runs them
(
SetStoryQuests). With them on, a quest can undo a place a player found, so a mode that keeps places keeps them off. The server says so in its log when both are on. - The map. The record does not keep the fog of war. A server with
map_fog = trueunder[ui]starts the fog again with every game and lifts it for a player withRevealPlayerMap. Without it, the default, the whole land shows from the start. What a restored record adds are the names of the places.RevealPlayerMap(pid, "places")also asks for the level’s places to be found, so the revealed map shows their names. No player’s game does that part in this version: the map is revealed without the names.
