Skip to content

Developer Diary

Robert Konigsberg edited this page Aug 16, 2022 · 5 revisions

Developer Diary

An infrequently-updated set of posts

2022-08-15 How to implement Undo. (Hint: it's hard)

It’s beginning to become clear just how difficult a fine-grained undo will be to implement.

Let’s consider what a fine-grained undo might look like with a real-world case: a player plays Imported Nitrogen.

image

Don’t even worry about any complications like using Helion, a simple Imported Nitrogen. This player has 2 cards that take microbes and 2 cards that take animals. So in a normal operation, the player will be asked to choose to place microbes, and then to place animals.

The code looks like this:

public play(player: Player) {
  player.plants += 4;
  player.increaseTerraformRating();
  player.game.defer(new AddResourcesToCard(player, CardResource.MICROBE, {count: 3}));
  player.game.defer(new AddResourcesToCard(player, CardResource.ANIMAL, {count: 2}));
  return undefined;
}

This means the deferredActionQueue will look like this:

[add microbes, add animal].

The engine goes through a series of steps which I’m sure during this first write-up will be incomplete and consequently unclear:

Player is presented with choosing which card gets the microbes Player is presented with choosing which card gets the animal.

At this point, the deferredActionQueue is empty: [] And waitingFor is SelectCard (for Animals)

The player wants to undo. What needs to happen?

The card with microbes has to be removed. The deferredActionQueue has to be rolled back The waitingFor needs to be SelectCard (for Microbes)

How do we do that? How do we keep track of these kinds of changes?

There are several approaches, but here’s one:

  1. Keep track of the state of the game every time a player input gets resolved, and the next playerInput is sent out. That state could be saved to the database, or possibly just stored, compressed, in memory. It’s not important. (I mean, it’s important, just not for discussing this problem.)
  2. When rolling back, the game takes the most recent game state and deserializes it. Now, normally this is done by creating a whole new Game instance, which means whole new Player instances. To update the existing Game and Player without touching existing instances would be new and different -- it’s not how deserializing currently works. (Later you’ll think about that second approach, so I’ll get back to it.)
  3. Somehow we need to restore the state of both deferredActionQueue, waitingFor and also waitingForCb.

That’s where things get really, really messy.

Let’s say we decide that the right approach is to keep every deferred action and every waitingFor until the player’s turn is fully over, and that way, it would be possible to not only restore the game state, but to restore it such that the player can continue with the appropriate PlayerInput.

However, PlayerInputs refer to a Game and Player by their instance, and so if a rollback is done by deserializing, that rollback will have different instances of Game and Player, and those will not line up with the ones in the waitingFor and deferredActionQueue.

So I guess that means having to write a restore that does not disrupt instances. And those instances are not just Gameand Player, but any Card or ISpace. Consequently, restoring is tricky.

Alternatives

Some alternatives are to be able to serialize every deferredAction. This also gets messy pretty fast. Here’s a deferredAction:

export type Options = {
 count?: number;
 restrictedTag?: Tags;
 title?: string;
 filter?: (card: ICard) => boolean;
 log?: () => void;
}
 
export class AddResourcesToCard extends DeferredAction {
 constructor(
   player: Player,
   public resourceType: CardResource | undefined,
   public options: Options = {},
 ) {
   super(player, Priority.GAIN_RESOURCE_OR_PRODUCTION);
 }

AddResourcesToCard has a filter. How does that get serialized? What about log?

By and large, each of these is manageable individually, but there are a couple hundred such operations. Making each serializable is a huge chunk of work, possibly too much?

It’s too bad that it would be quite that much work, because it would be fantastic to save them not just for small undo operations, but also when a turn is interrupted by restart.

2022-06-02

I've really been almost exclusively focusing on database API, which means that you'll see very few features for a while. It does seem that I broke undo, and I really need to prioritize fixing it. I will. It's just something I know so little about, and I'd rather just kill it for now. :D

Database

I've been changing and updating the API, and generating a couple of tests in the process. Here's what I've come to understand:

  1. A game is completely eliminated from the server after 10 days. You can't even clone it. There are good reasons to host your own server. This is one of them.

  2. Logs seem to take up about 30% of the games table. I had expected it to be much more. For reasons I won't go into, about half the log entries are duplicates, so a logs optimization would still only remove about 15% storage. Not the 80% drop I was hoping for. So even if we focus on significantly droping logs, it won't make a meaningful difference.

Card help

I added a new feature which turned out to be dead simple if I didn't care about internationalization or good UI. It's the new help icon. It could open a deluge of questions and options, so one thing at a time.

Aridor

There was recent issue with Aridor; it was totally broken. I found the source of that break finally. Kind of embarrassing poor code on my part. But, it's fixed. What a relief.

2022-05-29

Happy Memorial Day weekend in America! I wanted to throw out a couple of updates.

Internationalization

Thanks to everyone who is providing translations! I wish it were easier. More big translations are coming for German - the translations are done I just need to get to it.

Undo

Looks like I broken undo. A couple of reports are saying so. We don't support undo very well, and I almost don't want to have to go fix it, but I'll take a look shortly.

Roadmap Update

I posted a possible roadmap earlier this month at https://docs.google.com/document/d/1r1F4CFJDy6_yXei-4dsCdBL4B6SDlvSJHw3g1dPmjsI/edit#heading=h.yj2prikbf94n and it almost generated no communication at all. That's too bad.

Undo, and the database

I was going to start trying to deal with an undo solution, but there's a saying in software when trying to fix one thing means fixing something else first, which means something else. It's called "Yak Shaving" because the story is that in trying to fix the prior thing that is necessary for you to get progress, you eventually find yourself in a field with shears and a yak.

Well, in order to implement undo I would need to raise database consumption. How much do we use?

terraforming-mars::DATABASE=> SELECT pg_size_pretty( pg_database_size('REDACTED') );
 pg_size_pretty 
----------------
 6124 MB
(1 row)

terraforming-mars::DATABASE=> SELECT pg_size_pretty( pg_total_relation_size('games') );
 pg_size_pretty 
----------------
 5703 MB
(1 row)

terraforming-mars::DATABASE=> SELECT pg_size_pretty( pg_total_relation_size('game_results') );
 pg_size_pretty 
----------------
 413 MB
(1 row)

Our database holds 10GB, which means that if we double the amount of rows in the database, we'll run out of space.

So, OK, I guess my priority is to reduce database size. But before I can analyze that, I need to rewrite all our our database code so it is easier to test. What would make the database code easier to test? Using modern async await standards. Which is what I am doing now. But what's getting in the way? I broke the most recent undo. Yak shaving.