Skip to main content
  1. Posts/

New Day 8 - Let Slip the Dogs of Time

NewDays drag-spin-exp seitan-spin csharp ruby

A dog pushes forward the hands on a clock.
A dog pushes forward the hands on a clock.

In which we speed things up, tweak the chronometers, and remove a speedy dog.

Speeding Up the Starting Up
#

I was definitely a bit sloppy in my original implementation of live cell info in the database. Each cell gets pushed to the database individually, and there are more than 135k cells when all of the production maps are running. And all of those cells are pushed to the database when the game server starts up; it takes some time.

06/23/2023 03:15:07: {SystemGo} Drag Spin Exp 2.0.99.19
...
06/23/2023 03:19:18: {SystemGo} RoundEvent ends with round = 1. SignalTime was
 06/23/2023 03:15:22, this RoundEvent took 00:03:56.4813030, remaining = -00:03:51.4813030.

Four minutes and nine seconds between the first log message and the end of the first round.

Let’s wrap all of those database calls in a single transaction; the decrease in overhead should speed things up significantly…

06/23/2023 00:44:45: {SystemGo} Drag Spin Exp 2.0.99.19
...
06/23/2023 00:45:44: {SystemGo} RoundEvent ends with round = 1. SignalTime was
 06/23/2023 00:44:59, this RoundEvent took 00:00:44.4428500, remaining = -00:00:39.4428500.

59 seconds. So that’s an easy 4x speedup. Can we do better? How many cells are we putting in the database, anyway?

5/26/2023 1:36:47 PM: {Unknown} Round 1: Inserting 426095 live cells.
> SELECT COUNT(*)
FROM production.dbo.LiveCell

    |
------+
137876|

1 row(s) fetched.

We have a lot of duplicates in the list of cells to update in the first round. Let’s only add them to the update list if they’re not already there. Simplest way to do that?

DragonsSpine/DAL/DBWorld.cs
187
188
189
  if (cell != null && !cellsToUpdate.Contains(cell)) {
    cellsToUpdate.Add(cell);
  }

That should be even faster, right?

06/23/2023 00:38:27: {SystemGo} Drag Spin Exp 2.0.99.19
...
06/23/2023 00:42:06: {SystemGo} RoundEvent ends with round = 1. SignalTime was
 06/23/2023 00:41:52, this RoundEvent took 00:00:14.0122200, remaining = -00:00:09.0122200.

3 minutes 39 seconds, more than 3x slower. Seems counterintuitive. Until we look at the Cell class, which has dozens of fields and parameters; comparisons are certainly taking a not-insignificant amount of time.

Since we don’t need multiple copies of the same cell in the list, only the most recent update, some sort of set would be a much better fit. It appears the HashSet isn’t available with this version of .NET libraries, so we’ll try a Hashtable with an unused boolean value.

DragonsSpine/DAL/DBWorld.cs
130
  internal static Hashtable cellsToUpdate = new Hashtable();
DragonsSpine/DAL/DBWorld.cs
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
  internal static int SaveLiveCell(Cell cell) {
  int result = 0;
  lock (lockObjectLiveCellUpdate) {
    if (DragonsSpineMain.GameRound != DAL.DBWorld.roundToUpdate) {
    try {
      String sptouse = "prApp_LiveCell_Update";
      using (SqlConnection tempConnection = DataAccess.GetSQLConnection()) {
      Utils.Log("Round " + DragonsSpineMain.GameRound + ": Inserting " + DAL.DBWorld.cellsToUpdate.Count + " live cells.", Utils.LogType.Unknown);
      tempConnection.Open();
      SqlTransaction trans = tempConnection.BeginTransaction();
      foreach (Cell currCell in DAL.DBWorld.cellsToUpdate.Keys) {
        using (SqlStoredProcedure sp = new SqlStoredProcedure(sptouse, tempConnection, trans)) {
        sp.AddParameter("@lastRoundChanged", SqlDbType.Int, 4, ParameterDirection.Input, DAL.DBWorld.roundToUpdate);
        sp.AddParameter("@facet", SqlDbType.Int, 4, ParameterDirection.Input, currCell.FacetID);
        sp.AddParameter("@map", SqlDbType.Int, 4, ParameterDirection.Input, currCell.MapID);
        sp.AddParameter("@xCord", SqlDbType.Int, 4, ParameterDirection.Input, currCell.X);
        sp.AddParameter("@yCord", SqlDbType.Int, 4, ParameterDirection.Input, currCell.Y);
        sp.AddParameter("@zCord", SqlDbType.Int, 4, ParameterDirection.Input, currCell.Z);
        sp.AddParameter("@cellGraphic", SqlDbType.Char, 2, ParameterDirection.Input, currCell.CellGraphic);
        sp.AddParameter("@displayGraphic", SqlDbType.Char, 2, ParameterDirection.Input, currCell.DisplayGraphic);
        sp.ExecuteNonQuery();
        }
      }
      trans.Commit();
      DAL.DBWorld.cellsToUpdate.Clear();
      DAL.DBWorld.roundToUpdate = DragonsSpineMain.GameRound;
      }
    } catch (Exception e) {
      Utils.LogException(e);
      result = -1;
    }
    }
    if (cell != null) {
    cellsToUpdate[cell] = true;
    }
    return result;
  }
  }
06/23/2023 19:21:36: {SystemGo} Drag Spin Exp 2.0.99.19
...
06/23/2023 19:22:05: {SystemGo} RoundEvent ends with round = 1. SignalTime was
 06/23/2023 19:21:50, this RoundEvent took 00:00:15.0461640, remaining = -00:00:10.0461640.

Back down to half a minute, which seems good. Looking at Microsoft’s documentation, though, I see that it recommends using Dictionary instead of Hashtable. That’s an easy one-line change:

DragonsSpine/DAL/DBWorld.cs
130
  internal static Dictionary<Cell,bool> cellsToUpdate = new Dictionary<Cell,bool>();
06/23/2023 19:34:30: {SystemGo} Drag Spin Exp 2.0.99.19
...
06/23/2023 19:34:59: {SystemGo} RoundEvent ends with round = 1. SignalTime was
 06/23/2023 19:34:44, this RoundEvent took 00:00:14.5860260, remaining = -00:00:09.5860260.

And it’s still starting up within half-minute territory.

Could we optimize it further? Probably. But we’ll be changing things more drastically in the near future, so this is good enough for now.


More Control Over Time
#

Depending on the needs of the moment (quick test? relaxed play? full-speed simulation?), we want to be able to tweak the round timing a few different ways.

Environmental Overrides
#

While we’re adding configuration, we might as well add something I’ve been wanting for a while: let environment variables override the config file. That way we don’t have to touch the files within the container to alter the behavior of the server. For now we’ll just throw it in main():

DragonsSpine/DragonsSpineMain.cs
87
88
89
90
91
92
93
94
95
96
97
  public static int Main(string[] args)
  {
    SetInstance(new DragonsSpineMain());
    m_settings = GameServerSettings.Load();

    // Environment settings override
    APP_VERSION =    EnvOverride("DS_APP_VERSION",    APP_VERSION);
    CLIENT_VERSION = EnvOverride("DS_CLIENT_VERSION", CLIENT_VERSION);
    APP_NAME =       EnvOverride("DS_APP_NAME",       APP_NAME);
    APP_PROTOCOL =   EnvOverride("DS_APP_PROTOCOL",   APP_PROTOCOL);
    SQL_CONNECTION = EnvOverride("DS_SQL_CONNECTION", SQL_CONNECTION);
DragonsSpine/DragonsSpineMain.cs
1166
1167
1168
1169
1170
1171
1172
1173
  public static string EnvOverride(string key, string currValue) {
    if (Environment.GetEnvironmentVariable(key) != null) {
      Console.WriteLine("Environment variable " + key + ": " +
        Environment.GetEnvironmentVariable(key));
      return Environment.GetEnvironmentVariable(key);
    }
    return currValue;
  }

Could use some refactoring, but gives us what we need for now.

Complex Timing Config
#

For a good amount of flexibility in round timing, it seems like we need four parameters:

  1. The minimum round time.
  2. The maximum round time.
  3. Whether to wait for PC input once the minimum time has elapsed (until the maximum time has elapsed).
  4. Whether to use the minimum or maximum time in a world running with no PCs logged in.

NPCs and effects and other system functions occur every round (or every n rounds) regardless, so a sufficiently small minimum round time (e.g. 0) will mean iterating the rounds as quickly as they can be processed.

So we introduce these parameters into the config file:

App.config
 3
 4
 5
 6
 7
 8
 9
10
11
12
  <appSettings>
    <add key="APP_VERSION" value="2.0.99.19" />
    <add key="CLIENT_VERSION" value="0.0.1" />
    <add key="APP_NAME" value="Drag Spin Exp" />
    <add key="NEWS" value="^8/1/2015^Fork - This experimental version is a fork of version 2.0.16.5 of Dragon's Spine^^" />
    <add key="APP_PROTOCOL" value="Kesmai" />
    <add key="MIN_ROUND_TIME" value="0" />
    <add key="MAX_ROUND_TIME" value="9000" />
    <add key="WAIT_FOR_PCS" value="True" />
    <add key="FAST_EMPTY_WORLD" value="False" />
DragonsSpine/DragonsSpineMain.cs
15
16
17
18
19
20
21
22
23
24
25
  public class DragonsSpineMain
  {
  public static string APP_VERSION = ConfigurationManager.AppSettings["APP_VERSION"];
  public static string CLIENT_VERSION = ConfigurationManager.AppSettings["CLIENT_VERSION"];
  public static string APP_NAME = ConfigurationManager.AppSettings["APP_NAME"];
  public static string APP_PROTOCOL = ConfigurationManager.AppSettings["APP_PROTOCOL"];
  public static int MIN_ROUND_TIME = int.Parse(ConfigurationManager.AppSettings["MIN_ROUND_TIME"]);
  public static int MAX_ROUND_TIME = int.Parse(ConfigurationManager.AppSettings["MAX_ROUND_TIME"]);
  public static bool WAIT_FOR_PCS = bool.Parse(ConfigurationManager.AppSettings["WAIT_FOR_PCS"]);
  public static bool FAST_EMPTY_WORLD = bool.Parse(ConfigurationManager.AppSettings["FAST_EMPTY_WORLD"]);
  public static string SQL_CONNECTION = ConfigurationManager.AppSettings["SQL_CONNECTION"];
DragonsSpine/DragonsSpineMain.cs
 96
 97
 98
 99
100
101
  APP_PROTOCOL =   EnvOverride("DS_APP_PROTOCOL",   APP_PROTOCOL);
  MIN_ROUND_TIME =   int.Parse(EnvOverride("DS_MIN_ROUND_TIME", MIN_ROUND_TIME.ToString()));
  MAX_ROUND_TIME =   int.Parse(EnvOverride("DS_MAX_ROUND_TIME", MAX_ROUND_TIME.ToString()));
  WAIT_FOR_PCS =     bool.Parse(EnvOverride("DS_WAIT_FOR_PCS", WAIT_FOR_PCS.ToString()));
  FAST_EMPTY_WORLD = bool.Parse(EnvOverride("DS_FAST_EMPTY_WORLD", FAST_EMPTY_WORLD.ToString()));
  SQL_CONNECTION = EnvOverride("DS_SQL_CONNECTION", SQL_CONNECTION);

Now we’ll alter the logic at the end of RunGame() to skip forward if the factors align:

DragonsSpine/DragonsSpineMain.cs
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
  // If the round event is done; and we're past the minimum round time; and
  //  either: a) we have a pending command from each character in the game (or
  //  WAIT_FOR_PCS is false), or b) there are no PCs logged in and
  //  FAST_EMPTY_WORLD is true; then skip to the next round.
  int waitingForPCs = 0;
  if (m_roundTimer.Enabled && m_masterRoundStartTime.AddMilliseconds(MIN_ROUND_TIME) < DateTime.Now) {
    // We've already reached the minimum time, so check to see if we have to wait
    if (WAIT_FOR_PCS && Character.pcList.Count > 0) {
      // There are PCs about, and we should wait for them
      foreach (Character c in Character.pcList)
      {
        if (c.IsPC && c.PCState == Globals.ePlayerState.PLAYING &&
        ((c.inputCommandQueueCount() == 0 && c.cmdWeight == 0) ||
        c.IsFeared || c.Stunned > 0))
        {
          // Utils.Log("Waiting for PC: " + c.Name, Utils.LogType.SystemGo);
          waitingForPCs++;
        }
      }
    }
    if (waitingForPCs != wasWaitingForPCs) {
      // Log the "waiting for PCs" status, but only if it's changed.
      Utils.Log("Waiting for " + waitingForPCs + " PCs.", Utils.LogType.SystemGo);
      wasWaitingForPCs = waitingForPCs;
    }
    // If we don't have to wait or everyone's put in commands, fast forward.
    if ((Character.pcList.Count > 0 || FAST_EMPTY_WORLD) && waitingForPCs == 0) {
      Utils.Log("Removing round interval. Was: " + m_roundTimer.Interval, Utils.LogType.SystemGo);
      m_roundTimer.Interval = 1; // let the round timer immediately fire
    }
  }

  System.Threading.Thread.Sleep(100);

Still feels a bit messy; but let’s see if it works well.


One More Thing
#

Paw Patrol logo
Paw Patrol logo

              dog                                      fur

     ~~~~~~~~~~~~
     ~~. . . . .
     ~~. v$. . .
     ~~. . . . .
     ~~. . . . .
     ~~. . . . .

The dog savages you with it's teeth!
You have been slain!
You are dead, you can either wait to be resurrected or rest.







 ->look / this is fine

 R bottle                    Hits       : 0/36             Hits Taken : 36
 L bottle                    Experience : 4996  
               Stamina    : 10  

When we run the tests against the server with FAST_EMPTY_WORLD set, there’s an issue, specifically with the delightful scenario UnderworldEnabled True, online evil low-constitution burnt corpse goes there. It seems like the (lawful) dog has time to roam around the minimal map before the (evil) test player logs in; instead of the test going as planned, the test player is dispatched by this local canine crimefighter.

Luckily we can just disable the dog’s spawn zone before starting the server, and our evil test player is safe:

features/lib/db_helper_sql.rb
134
135
136
137
138
139
140
def disable_spawn_zones()
  client = connect_to_db(@server_database)
  result = client.execute("UPDATE [#{@server_database}].[dbo].[SpawnZone] SET [enabled] = 0")
  affected_rows = result.do
  fail "Failed to disable spawn zones!" if 1 != affected_rows
  affected_rows
end

Ultimately, FAST_EMPTY_WORLD created too much variance in what happens while the test player is logging in, so I left it turned off for testing. But it will be more useful soon.


More to come
More to come

drag-spin-exp New Day 8 Code

seitan-spin New Day 8 code