Skip to main content
  1. Posts/

New Day 7 - Faster and More Stable

NewDays drag-spin-exp seitan-spin GitLab Docker cucumber ruby csharp

Repairing a car while driving fast.
Repairing a car while driving fast.

In which we balance fixing things and going faster.

What Have I Broken?
#

The test suites running against a minimal game database were relatively stable, but when I started up the mono-based game server against the production database, it would eventually stop running. Sometimes logging in a player would work, sometimes they’d be greeted with a blinking cursor and nothing more. There wasn’t even a useful exception thrown that would show up in the output.

...
06/14/2023 21:33:51: NPCs: [1741] | Players: [0] | CPU: [??%] | Rnd: [83]
06/14/2023 21:39:19: NPCs: [1741] | Players: [0] | CPU: [??%] | Rnd: [89]
...hours pass with nothing...

A Sea of Timers
#

Given all of the changes in environment, I was concerned about the timing. It had already become slow to start up after I started pushing so much data out to the database. The functioning of the game server depends on a large set of repeated timers:

  • RoundEvent, 5 seconds
  • InactivityEvent 10 seconds
  • JanitorEvent, 10 seconds
  • UpdateServerStatus. 30 seconds
  • SaveEvent, 30 seconds
  • World.ShiftDailyCycle 1800 seconds
  • World.ShiftLunarCycle 7200 seconds
  • For each Character:
    • RoundEvent, 5 seconds
    • ThirdRoundEvent, 15 seconds
  • For each NPC:
    • NPCEvent, 5 seconds
  • For each Effect:
    • AreaEffectEvent, 5 seconds
    • CharacterEffectEvent, 5 seconds

If everything goes according to plan, all of these go off after some multiple of the time for one round (currently 5 seconds). Let’s make sure we understand how the RoundEvent is going, and track its incrementing of the round counter.

DragonsSpine/DragonsSpineMain.cs
559
560
561
562
private static void RoundEvent(object sender, ElapsedEventArgs eventArgs)
{
  Utils.Log("RoundEvent starts with round = " + DragonsSpineMain.GameRound + ". SignalTime was " +
    eventArgs.SignalTime.ToString(), Utils.LogType.SystemGo);
DragonsSpine/DragonsSpineMain.cs
712
713
714
715
716
717
718
719
  /* Save live Cell data on round 1, to handle maps with no activity */
  if (DragonsSpineMain.GameRound == 1) DAL.DBWorld.SaveLiveCell(null);

  Utils.Log("RoundEvent ends with round = " + DragonsSpineMain.GameRound + ". SignalTime was " +
    eventArgs.SignalTime.ToString() + ", this RoundEvent took "
  + (DateTime.Now - eventArgs.SignalTime).ToString() + ".", Utils.LogType.SystemGo);

}
06/14/2023 22:44:44: {SystemGo} Starting main game loop.
> 06/14/2023 22:44:49: {SystemGo} RoundEvent starts with round = 0. SignalTime was 06/14/2023 22:44:49
> 06/14/2023 22:48:35: {SystemGo} RoundEvent ends with round = 1. SignalTime was 06/14/2023 22:44:49,
 this RoundEvent took 00:03:45.9208470.
> 06/14/2023 22:48:35: {SystemGo} RoundEvent starts with round = 1. SignalTime was 06/14/2023 22:48:35
> 06/14/2023 22:48:35: {SystemGo} RoundEvent ends with round = 2. SignalTime was 06/14/2023 22:48:35,
 this RoundEvent took 00:00:00.0013760.
...
> 06/14/2023 23:02:09: {SystemGo} RoundEvent starts with round = 208. SignalTime was 06/14/2023 23:02:09
> 06/14/2023 23:02:09: {SystemGo} RoundEvent ends with round = 209. SignalTime was 06/14/2023 23:02:09,
 this RoundEvent took 00:00:00.0039720.
> 06/14/2023 23:02:14: NPCs: [1722] | Players: [0] | CPU: [??%] | Rnd: [209]
06/14/2023 23:02:14: {SystemGo} RoundEvent starts with round = 209. SignalTime was 06/14/2023 23:02:14
> 06/14/2023 23:02:14: {SystemGo} RoundEvent ends with round = 210. SignalTime was 06/14/2023 23:02:14,
 this RoundEvent took 00:00:00.0032490.
...

And it just keeps going; initially, the log messages seem to have resolved the issue, which is unfortunate. On a later run, however, I see an issue:

06/15/2023 00:37:17: {SystemGo} Starting main game loop.
06/15/2023 00:37:22: {SystemGo} RoundEvent starts with round = 0. SignalTime was 06/15/2023 00:37:22
06/15/2023 00:37:27: {SystemGo} RoundEvent starts with round = 1. SignalTime was 06/15/2023 00:37:27

Five seconds into round 0, before it ends (we do a large write to the database in the first round, which takes more than five sconds), we see round 1 starting. The RoundEvent being called when it is already in progress seems like the behavior to expect when Timer.AutoReset is set to true, but I’m not sure why I’m not seeing it all of the time.

One solution is to set Timer.AutoReset to false and restart the timer manually when the event is done. At the end of the RoundEvent we’ll set the timer’s interval based on how much time we’ve already taken. Let’s try that:

DragonsSpine/DragonsSpineMain.cs
242
243
244
245
246
  m_roundTimer = new System.Timers.Timer();
  m_roundTimer.Elapsed += new ElapsedEventHandler(RoundEvent); // player rounds (5 seconds)
  m_roundTimer.Interval = 5000;
  m_roundTimer.AutoReset = false;
  m_roundTimer.Start();

DragonsSpine/DragonsSpineMain.cs
567
568
569
570
private static void RoundEvent(object sender, ElapsedEventArgs eventArgs)
{
  Utils.Log("RoundEvent starts with round = " + DragonsSpineMain.GameRound + ". SignalTime was " +
    eventArgs.SignalTime.ToString(), Utils.LogType.SystemGo);
DragonsSpine/DragonsSpineMain.cs
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
  TimeSpan timeTaken = DateTime.Now - eventArgs.SignalTime;
  TimeSpan timeRemaining = TimeSpan.FromMilliseconds(5000) - timeTaken;
  Utils.Log("RoundEvent ends with round = " + DragonsSpineMain.GameRound + ". SignalTime was " +
    eventArgs.SignalTime.ToString() + ", this RoundEvent took "
  + timeTaken.ToString() + ", remaining = " + timeRemaining.ToString() + ".", Utils.LogType.SystemGo);

  if (timeRemaining.TotalMilliseconds < 0) {
    m_roundTimer.Interval = 100;                
  } else {
    m_roundTimer.Interval = timeRemaining.TotalMilliseconds;
  }

  Utils.Log("round timer interval is " + m_roundTimer.Interval + ".", Utils.LogType.SystemGo);
  m_roundTimer.Start();

}

This appears to work: the closest the RoundEvents come is every five seconds, and any slowness just delays the next one. On the production database, the server survives 1000+ rounds with no freezing.

One Timer to Rule Them All
#

Since all of the other timers are multiples of the main round timer, why are they all firing separately? I have to assume this is for performance: having these many timer events fire separately, they each get a separate thread out of the thread pool and run somewhat concurrently. Massive credit due to the original authors: it worked well, and the server was very reliable given all of the moving parts.

However, it looks like my transition to Mono has surfaced some differing timer behavior. Also, I’d like to be able to tweak the round timing a bit more, for testing and simulation purposes. So let’s have the RoundEvent manually call each of the other timer events instead:

DragonsSpine/DragonsSpineMain.cs
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
  foreach (Character ch in new List<Character>(Character.allCharList)) {
    Utils.Log("Calling round events for character " + ch.GetID() + " " + ch.Name + 
    " with round = " + DragonsSpineMain.GameRound + ".", Utils.LogType.SystemGo);

    ch.RoundEvent(sender, eventArgs);
    if (DragonsSpineMain.GameRound % (15000 / 5000) == 0) {
      ch.ThirdRoundEvent(sender, eventArgs);
    }
  }

  Utils.Log("Processing " + Character.NPCList.Count + " NPCs.", Utils.LogType.SystemGo);
  for (int a = 0; a < Character.NPCList.Count; a++)
  {
    Utils.Log("NPC " + a, Utils.LogType.SystemGo);
    Character c = Character.NPCList[a];
    NPC npc = c as NPC;
    if (npc == null) {
      Utils.Log("NPC " + a + " " + c.Name + " is not an NPC.", Utils.LogType.SystemGo);
    } else {
      Utils.Log("Calling NPC events for NPC " + npc.GetHashCode() + " " + npc.GetID() + " " + npc.Name + ".", Utils.LogType.SystemGo);
      npc.NPCEvent(sender, eventArgs);
    }
  }

  Utils.Log("Processing " + Effect.allCharEffectList.Count + " character effects.", Utils.LogType.SystemGo);
  foreach (Effect effect in new List<Effect>(Effect.allCharEffectList)) {
    Utils.Log("Calling character effect events for effect " + effect.effectType.ToString() + ".", Utils.LogType.SystemGo);

    effect.CharacterEffectEvent(sender, eventArgs);
  }

  Utils.Log("Processing " + Effect.allAreaEffectList.Count + " area effects.", Utils.LogType.SystemGo);
  foreach (Effect effect in new List<Effect>(Effect.allAreaEffectList)) {
    Utils.Log("Calling area effect events for effect " + effect.effectType.ToString() + ".", Utils.LogType.SystemGo);
    effect.AreaEffectEvent(sender, eventArgs);
  }

  Utils.Log("Calling intermittent round events", Utils.LogType.SystemGo);            
  if (DragonsSpineMain.GameRound % (10000 / 5000) == 0) {
    JanitorEvent(sender, eventArgs);
    InactivityEvent(sender, eventArgs);
  }
  if (DragonsSpineMain.GameRound % (30000 / 5000) == 0) {
    UpdateServerStatus(sender, eventArgs);
    SaveEvent(sender, eventArgs);
  }
  if (DragonsSpineMain.GameRound % (1800000 / 5000) == 0 ) {
    World.ShiftDailyCycle(sender, eventArgs);
  }
  if (DragonsSpineMain.GameRound % (7200000 / 5000) == 0 ) {
    World.ShiftLunarCycle(sender, eventArgs);
  }

Tripping Over a Wizard Eye
#

The code that calls the NPC’s NPCEvent each round looks kind of different; what’s up?

In testing these changes, I ran into a few failures. Some tests take into account the healing a character gets every three rounds; the “third round” has shifted, so these needed to be tweaked. More interestingly, the two tests that involve the spell “Wizard Eye” were failing badly.

features/player_effects_2.feature
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
## Wizard_Eye
Scenario: Wizard Eye potion results in OOBE
  Given I use the "minimal" database as-is
  And I add player and character "TestHS01"
  And I create an "Wizard_Eye" potion of amount "0" and duration "2"
  And I put the item in the character's left hand
  And I start the server and play
  When I open and drink "bottle"
  Then I saw myself
  And I see my doppelganger in a trance
  And I rest
  And I did not see myself

Scenario: Wizard Eye potion effect visible by others
  Given I use the "minimal" database as-is
  And I add player and character "TestHS01"
  And I create a "Wizard_Eye" potion of amount "0" and duration "50"
  And I put the item in the character's left hand
  And I start the server and play
  When I open and drink "bottle"
  And I add player and character "TestHS02"
  And I log on as "TestHS02"
  And I enter the game
  Then I saw a "toad"
  And I see "TestHS01_name" in a trance
 ## Wizard_Eye
  Scenario: Wizard Eye potion results in OOBE                          # features/player_effects_2.feature:449
  Given I use the "minimal" database as-is                           # features/steps/server_steps.rb:3
  And I add player and character "TestHS01"                          # features/steps/account_steps.rb:12
  And I create an "Wizard_Eye" potion of amount "0" and duration "2" # features/steps/character_steps.rb:46
  And I put the item in the character's left hand                    # features/steps/character_steps.rb:59
  And I start the server and play                                    # features/steps/server_steps.rb:45
  When I open and drink "bottle"                                     # features/steps/character_steps.rb:81
  Then I saw myself                                                  # features/steps/character_steps.rb:448
  And I see my doppelganger in a trance                              # features/steps/character_steps.rb:458
    Net::ReadTimeout with "timed out while waiting for more data" (Net::ReadTimeout)
    ./features/lib/misc_helper.rb:110:in `telnet_command'
    ./features/steps/character_steps.rb:460:in `/^I see my doppelganger in a trance$/'
    features/player_effects_2.feature:457:in `I see my doppelganger in a trance'
  And I rest                                                         # features/steps/character_steps.rb:394
  And I did not see myself                                           # features/steps/character_steps.rb:453

  Scenario: Wizard Eye potion effect visible by others                 # features/player_effects_2.feature:461
  Given I use the "minimal" database as-is                           # features/steps/server_steps.rb:3
  And I add player and character "TestHS01"                          # features/steps/account_steps.rb:12
  And I create a "Wizard_Eye" potion of amount "0" and duration "50" # features/steps/character_steps.rb:46
  And I put the item in the character's left hand                    # features/steps/character_steps.rb:59
  And I start the server and play                                    # features/steps/server_steps.rb:45
  When I open and drink "bottle"                                     # features/steps/character_steps.rb:81
  And I add player and character "TestHS02"                          # features/steps/account_steps.rb:12
  And I log on as "TestHS02"                                         # features/steps/login_steps.rb:201
  And I enter the game                                               # features/steps/login_steps.rb:277
    Net::ReadTimeout with "timed out while waiting for more data" (Net::ReadTimeout)
    ./features/lib/misc_helper.rb:110:in `telnet_command'
    ./features/steps/login_steps.rb:278:in `/^I enter the game$/'
    features/player_effects_2.feature:470:in `I enter the game'
  Then I saw a "toad"                                                # features/steps/character_steps.rb:472
  And I see "TestHS01_name" in a trance                              # features/steps/character_steps.rb:465

Failing Scenarios:
cucumber features/player_effects_2.feature:449 # Scenario: Wizard Eye potion results in OOBE
cucumber features/player_effects_2.feature:461 # Scenario: Wizard Eye potion effect visible by others

Net::ReadTimeout? Sounds like a crash of some sort, but nothing in the logs and the server executable keeps running. And why only in these two tests?

As you may guess from the above final code, Wizard Eye leaves us with a member of the NPC list that’s not actually an NPC; so casting it to NPC to call NPCEvent() was throwing an exception. Since the exception was in the timer event, not on the main thread, it wasn’t caught. And since it happened before the timer had been reset, the timer never fired again (hence no response to the test user).

So. Some danger in not using AutoReset if the timer event is able to crash quietly. And what I’ve done by checking for non-NPC NPCs is just a bandaid for these two tests. We should handle exceptions in the one master timer event more gracefully.

Good Enough?
#

124 scenarios (124 passed)
1242 steps (1242 passed)
111m55.772s

Yes, but kind of slow.


Do You Want to Go Faster?
#

There is one question that every engineer attached to Test or Ops has heard time and time again: “How can we make the tests faster? (but still reliable and correct, of course.) (and with the same resources or less, of course.)” Sometimes it’s possible, sometimes it takes a lot of work, and sometimes it requires changes in the product code.

Whenever considering putting a “test mode” in the product, a lot of caution is called for. It becomes a question of how useful the testing is, given that the code paths being tested are not necessarily those exercised in production. In the past I’ve recommended using such a mode as an initial screening step, especially when test resources are scarce, but using a full production-mode test run for any release.

Turbo Rounds Enabled
#

In this case, we’ll implement a speedup that doesn’t rely on communicating that the server is being tested. Regular players will see the same speedup under the right circumstances. Let’s try:

  • If there is time left before the round timer would increment,
  • and all effects and NPCs have been processed,
  • and all players have entered their commands,
  • skip to the next round.

Simple enough to describe, with a few interesting details that come up during implementation. Here’s the code I added:

DragonsSpine/DragonsSpineMain.cs
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
  while (DragonsSpineMain.ServerStatus <= DragonsSpineMain.ServerState.Locked)
  {
    this.CleanupLists();
    io.HandleNewConnections();
    io.GetInput();
    io.ProcessRealTimeCommands();
    io.SendOutput();

    // If we have a pending command from each character in the game, go ahead and
    //  process the next round.
    if (Character.pcList.Count > 0) {
      int waitingForPCs = 0;
      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++;
        }
      }
      Utils.Log("Waiting for " + waitingForPCs + " PCs.", Utils.LogType.SystemGo);

      if (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);
  }

On each run through the main game loop, after all of the processing, we run through the list of theoretical PCs.

  • If it’s really a PC,
  • and they’re in the game (PLAYING) rather than at a menu or chatting,
  • and they either:
    • have not yet given a command (empty queue, current command weight is 0),
    • are Feared (and therefore unable to give commands)
    • or are Stunned (ditto)
  • then we need to continue to wait for the round timer.

Otherwise, we fast-forward the round timer interval to 1ms. If we’re between round events, it’ll trigger as soon as this thread hits Sleep(). If we’re in mid-event, the timer is disabled and this will have no effect.

Does it work?

Petting a dog - original speed
Petting a dog - original speed
Petting a dog - new speed
Petting a dog - new speed

Looks good. I’d bet all those who paid by the minute to play would’ve appreciated it.

Simpler Inter-Test Resets
#

Now that we’re in easily-refreshed containers, we don’t really need to clean up after individual tests; we can just bounce the database and/or game server.

features/lib/hooks.rb
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Before('@kill_server_after') do
  verify_containers_up()
end

Before('@reset_server') do
  # Kill the server if it's already running
  take_containers_down()

  @server_database = 'minimal'
  # Bring up the containers
  verify_containers_up()

  # Start up the server
  set_db_in_config(@server_database)
  start_server
  # Wait for server to come up
  connect_to_db(@server_database)
  log_contains('Starting main game loop.')
end

After('@kill_server_after') do
  if take_containers_down()
  stdout, stderr, status = Open3.capture3("rm -r gameserver/*")
  debug_msg "Result of rm -r gameserver/*: #{status.inspect}"
  debug_msg "  stdout: #{stdout}" if stdout
  debug_msg "  stderr: #{stderr}" if stderr
  end
  status == 0
end

For tests that manually control the execution of the game server, we use @kill_server_after to bring up the containers before each test, and take them down and clear out the bound volume after. For tests that expect the server to be running, we use @reset_server instead. We were able to retire such hooks a @restore_config_file, @db_cleanup, and @clear_player_effects.

It’s certainly possible that the “cleaning up” between tests was a little faster, depending on the speed of database calls compared to Docker actions; but the “clean slate” approach certainly feels safer.

Better and Faster Now?
#

124 scenarios (1 flaky, 123 passed)
1257 steps (1 failed, 9 skipped, 1247 passed)
83m20.542s

Hmm, a bit faster but now one’s flaky. Need to look at that. Tomorrow.


One More Thing
#

Speaking of timing, let’s look at the daily and lunar cycles.

World/World.cs
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  public enum DailyCycle
  {
    Morning,
    Afternoon,
    Evening,
    Night
  }
  public enum LunarCycle
  {
    New,
    Waxing_Crescent,
    Waning_Crescent,
    Full
  } 
World.cs
66
  public static string[] DaysOfTheWeek = { "Nanna", "Enki", "Inanna", "Utu", "Gugalanna", "Enlil", "Ninurta" }; // days of the week
DragonsSpineMain.cs
260
261
262
263
264
265
266
267
268
269
270
  m_chronoTimer = new System.Timers.Timer();
  m_chronoTimer.Elapsed += new ElapsedEventHandler(World.ShiftDailyCycle); // time change (30 minutes)
  m_chronoTimer.Interval = 1800000;
  m_chronoTimer.Start();
  Utils.Log("Chronology timer started.", Utils.LogType.SystemGo);

  m_lunarTimer = new System.Timers.Timer();
  m_lunarTimer.Elapsed += new ElapsedEventHandler(World.ShiftLunarCycle); // moon phases timer (90 minutes)
  m_lunarTimer.Interval = 7200000;
  m_lunarTimer.Start();
  Utils.Log("Lunar cycle timer started.", Utils.LogType.SystemGo);

Four moon phases * 90 minutes? 6 hours.

Seven days of the week * four times of day * 30 minutes? 14 hours.

Looks like there’ll need to be some way to set these values in order to test their effects, but just for fun let’s bypass all the timers, scale down the database output, and see how fast we can get through them:

 06/15/2023 14:46:30: ShiftDailyCycle: Round 360, Daily cycle is now Afternoon, current day is now Nanna.
 06/15/2023 14:48:49: ShiftDailyCycle: Round 720, Daily cycle is now Evening, current day is now Nanna.
 06/15/2023 14:51:03: ShiftDailyCycle: Round 1080, Daily cycle is now Night, current day is now Nanna.
 06/15/2023 14:53:17: ShiftDailyCycle: Round 1440, Daily cycle is now Morning, current day is now Enki.
 06/15/2023 14:53:17: ShiftLunarCycle: Round 1440, Lunar cycle is now Waxing_Crescent, current day is now Enki.
 06/15/2023 14:55:32: ShiftDailyCycle: Round 1800, Daily cycle is now Afternoon, current day is now Enki.
 06/15/2023 14:57:48: ShiftDailyCycle: Round 2160, Daily cycle is now Evening, current day is now Enki.
 06/15/2023 15:00:03: ShiftDailyCycle: Round 2520, Daily cycle is now Night, current day is now Enki.
 06/15/2023 15:02:18: ShiftDailyCycle: Round 2880, Daily cycle is now Morning, current day is now Inanna.
 06/15/2023 15:02:18: ShiftLunarCycle: Round 2880, Lunar cycle is now Waning_Crescent, current day is now Inanna.
 06/15/2023 15:04:33: ShiftDailyCycle: Round 3240, Daily cycle is now Afternoon, current day is now Inanna.
 06/15/2023 15:06:49: ShiftDailyCycle: Round 3600, Daily cycle is now Evening, current day is now Inanna.
 06/15/2023 15:09:04: ShiftDailyCycle: Round 3960, Daily cycle is now Night, current day is now Inanna.
 06/15/2023 15:11:20: ShiftDailyCycle: Round 4320, Daily cycle is now Morning, current day is now Utu.
 06/15/2023 15:11:20: ShiftLunarCycle: Round 4320, Lunar cycle is now Full, current day is now Utu.
 06/15/2023 15:13:34: ShiftDailyCycle: Round 4680, Daily cycle is now Afternoon, current day is now Utu.
 06/15/2023 15:15:49: ShiftDailyCycle: Round 5040, Daily cycle is now Evening, current day is now Utu.
 06/15/2023 15:18:05: ShiftDailyCycle: Round 5400, Daily cycle is now Night, current day is now Utu.
 06/15/2023 15:20:22: ShiftDailyCycle: Round 5760, Daily cycle is now Morning, current day is now Gugalanna.
 06/15/2023 15:20:22: ShiftLunarCycle: Round 5760, Lunar cycle is now New, current day is now Gugalanna.
 06/15/2023 15:22:38: ShiftDailyCycle: Round 6120, Daily cycle is now Afternoon, current day is now Gugalanna.
 06/15/2023 15:24:54: ShiftDailyCycle: Round 6480, Daily cycle is now Evening, current day is now Gugalanna.
 06/15/2023 15:27:10: ShiftDailyCycle: Round 6840, Daily cycle is now Night, current day is now Gugalanna.
 06/15/2023 15:29:28: ShiftDailyCycle: Round 7200, Daily cycle is now Morning, current day is now Enlil.
 06/15/2023 15:29:28: ShiftLunarCycle: Round 7200, Lunar cycle is now Waxing_Crescent, current day is now Enlil.
 06/15/2023 15:31:45: ShiftDailyCycle: Round 7560, Daily cycle is now Afternoon, current day is now Enlil.
 06/15/2023 15:34:01: ShiftDailyCycle: Round 7920, Daily cycle is now Evening, current day is now Enlil.
 06/15/2023 15:36:18: ShiftDailyCycle: Round 8280, Daily cycle is now Night, current day is now Enlil.
 06/15/2023 15:38:35: ShiftDailyCycle: Round 8640, Daily cycle is now Morning, current day is now Nanna.

A week in less than an hour isn’t bad, but still not fast enough for a speedy test cycle.


More to come
More to come

drag-spin-exp New Day 7 Code

seitan-spin New Day 7 code