We fix why CI has failed since episode 28, and then add a skill trainer, an NPC who teaches you a skill once you reach its level. The server checks each lesson, where a test catches the level we forgot, the client gets a window to learn in, and what players learn is saved through Redis to MySQL. This is episode 34 of a series about building an MMORPG from scratch in C++, with the focus on the engineering around the game.
The code
Each step on screen is a tag in the game's repository. The code at the end of the episode is ep034/end.
- 2:44
ep034/step-1CI, red since episode 28BrowseChanges - 5:36
ep034/step-2A trainer, as contentBrowseChanges - 10:18
ep034/step-3Learning, on the serverBrowseChanges - 15:25
ep034/step-4Learning, on the serverBrowseChanges - 16:01
ep034/step-5Over the wireBrowseChanges - 18:36
ep034/step-6A window to learn inBrowseChanges - 26:08
ep034/step-7Saving what players learnBrowseChanges - 31:39
ep034/step-8Bots that use their skillsBrowseChanges
Transcript
Intro
0:00 Hello and welcome back to episode thirty-four, where we're going to give our game a trainer, who teaches the players their skills, and save what each player learns. Let's begin with a quick look at where we're going to be by the end of the episode.
0:15 Alice is level four, and the trainer next to her lists the skills it teaches. She learns the whirlwind, and the kick, and both light up on her hotbar, so when the skeletons come for her, she can spin and kick them back.
0:33 On the website of the series you can find more information about the project. The code for the project is open source on GitHub, tagged at each step of each episode so that you can follow along.
Last time
0:45 Now, before we get to today's work, let's quickly go over what we did last time. The client drew the world into a frame of its own, sized in pixels once it came out coarse on a high-DPI screen. The bloom took what's brighter than white, in a frame of half floats, and spread it out over the picture. The lava and second wind's glow gave off light of their own, past white, so the bloom spreads them too. Cel shading put the sun's light in flat bands, with an outline around each figure. The settings window got the bloom, the cel shading and the anti-aliasing, kept with the sound, and FXAA smoothed the edges of the world.
CI, red since episode 28
1:24 Before the trainer, something we missed: CI on GitHub has failed on each push since episode twenty-eight, where the coverage report went into it. The tests pass, but the last step can't read one of the report's folders, as it packs the docs and the report up for GitHub Pages. On GitHub's runner, the container writes the report as root, and the upload runs as the runner's own user, so it's refused. On a Mac, Docker Desktop hands the report's files to us, so it opened fine on our machine. Since episode thirty-two, clang-tidy fails before that, on two errors that our machine shows too. And fixing the upload raises a question of its own: Pages serves a site that anyone can read, so a private copy of the repository shouldn't put its code there.
2:10 At the end of episode thirty-three, we build the tidy stage as CI does, and grep its errors, as we did in episode twenty-eight. Grep keeps the lines with Docker's step numbers, as Docker prints the end of a failed step twice, and sed takes the source folder off each path, and the check's name off the end. Settings dot c c includes a header that protoc didn't write, as the tidy stage builds the protocol's and the content's, and not the client's. And in the client's main, clang-tidy flags the world the login screen hands back, an optional read without a check it can follow.
2:44 In coverage dot sh, once the report is written, chmod gives all users read access to its files and folders. The capital X lets them open the folders too, without making the HTML files executable. LLVM cov's folders shut out other users, and in CI the pages upload reads them as another user. In the Dockerfile, the tidy stage builds the client's core too, as building it is what has protoc write settings dot p b dot h. That builds what the core links first, raylib and ImGui among them, which makes the tidy job longer. A target for the generated headers alone would be quicker, but it's a list of headers we'd have to keep in step with the protos. In the client's main, the else that enters a world becomes an else if, which takes the world from the login screen, as a reference to the optional it keeps, checks that it's there, and hands it to Enter. The login screen builds its login form once a world is picked, and not before, but clang-tidy can't follow that from here, and neither can someone reading this line. In CI, the docs job deploys to Pages from a public repository, and skips a private one. Outside GitHub Enterprise, a private repository's Pages site is still public, and the coverage report shows each of our source files in full, so that would put the private code where anyone can read it. And the README says where CI publishes the docs and the report, and from which copy.
4:11 We build the tidy stage again, at this step, and grep counts no errors, so CI's clang-tidy step is going to pass too.
4:19 And after a coverage run, the report's folders let other users in now, with r and x at the end of their modes, which is all the upload needs. With those fixed, let's get on with today's work, the trainer.
Today
4:33 In episode thirty-one, our players got skills, and each one came on its own as soon as a player reached its level. In most MMOs, you learn a skill from someone in the game instead, a trainer, who teaches it once you're ready, often for some gold. We don't have gold yet, so ours is going to teach for free. The trainer is going to be content, like our other NPCs, with the skills it teaches written down next to its name and look. The server decides each lesson, as it decides everything else: whether the NPC teaches the skill, whether you stand close enough, whether you've reached the skill's level, and whether you know it already. The client gets a window to learn in, which opens when you click the trainer, and the hotbar shows which skills you don't know yet. What a player learns has to outlast their session, so it gets saved like the rest of the player, in Redis after each tick, and in MySQL through our saver, in a table of its own. And the bots are going to use the skills they know, so that our load tests send skills like players do. All right, let's get started, with the content.
A trainer, as content
5:37 We start with what a new player knows, in skills dot proto. The file's message, skills, gets a second field, starting, which is a list of skill ids, as a game could start its players with more than one. And above it, we write what it is: what each new player knows, by id, while the others they learn from a trainer. In skills dot t x t p b, starting is one, the thrust, so that a new player still has a skill to fight with before they meet anyone. Skills dot h includes set, which the parts already use for the players they've seen. The skill table keeps the starting skills as a set, next to the skills by id, and the struct's comment says so now. Parse skills gets a new refusal in its list: a starting skill that isn't there. In skills dot c c, set's include goes in too. After the loop over the skills, we build the set from the file's list, which also drops a skill that's named twice. Each starting skill has to be one of the skills we read, or it's an error with the missing id, as a typo there would start each player without the skill we meant. And the table we give back gets the set, moved into it, after the skills. In npcs dot proto, a kind of NPC gets a list of the skills it teaches, by their ids in the skills file, and the comment over it says that an NPC that teaches is a trainer. Talks says what an NPC does too, as one that talks tells stories, so a kind could do both. In npcs dot t x t p b, the sixth kind is the trainer, which teaches skills two, three and four, the kick, second wind and the whirlwind. Until NPCs have models, it's a silver ball, with a radius of half a metre, like the storyteller's. It stands two and a half metres west of the middle and three north, which is just under four metres from where new players start, and the Ashlands get one in that spot too. The skills a trainer teaches are in another file than the trainer, and each file is parsed on its own, so no single parse function can check them. So content dot h gets check content, which takes the whole content once it's read, for what the whole folder can tell is wrong, and no single file can: a trainer that teaches a skill the skills file doesn't have. In content dot c c, read content calls it once each file is in, and gives back what's wrong with the NPCs file's name in front, as the errors from each file have. Check content goes through the kinds, and through each skill a kind teaches. It stops at the first one that's missing, like the parse functions do. A skill that isn't among the skills is refused, with an error that names the kind and the id. Further down, the client hash copies what the clients read of each kind, its id, name, look and whether it talks. Now it gets what each kind teaches too, as the client is going to list a trainer's skills in a window, and a client whose trainers teach other skills than the world's would offer lessons that the world refuses. In the content tests, the test that reads the game's content checks that there are starting skills, and a kind of NPC that teaches. A new player needs the thrust to fight with, and a trainer to learn the rest from, and a line deleted from either file would still parse. The test for NPCs that don't add up reads the game's content first, which check content accepts, so we know that a refusal after it comes from what we change. Then it has the guard teach skill ninety-nine, which isn't there, and expects the error that names them both. And the test for skills that don't add up gets one more case, at the end, after the kick that lands after it's over. Its file has a single skill, a kick, with the id one and a level of its own, so that the starting skill is what it gets wrong. It starts players with skill two, in a file that has skill one, and expects that error, with the id that's missing. The README's section on content has the two new fields, and what read content checks once the files are in. Its section on NPCs says that each land has a trainer now, the silver ball a few steps from where players start.
9:36 The content tests pass, so the game's trainer teaches skills that are there, and the checks refuse a trainer and a starting skill that aren't.
Learning, on the server
9:45 On the server, a lesson starts with a player asking a trainer for a skill, by the trainer's id and the skill's. The world checks that the NPC is there and teaches that skill, and that the player stands within four metres of it, as close as you'd stand to talk to someone. Then it checks that they've reached the skill's level, and that they don't know it already. Then the skill is theirs, and using it no longer needs a level, just the skill and its cooldown. Each check that fails comes back as an error the player can read, so that a click that does nothing says why.
10:18 In skills dot proto, a skill's level used to be the level a player can use it from. Now it's the level a player can learn it from, as a skill you know is yours to use. In world dot h, set's include goes in, and absl's status, for what learning gives back. A player gets the skills they know, by their ids in the skills file, in a set. We look a skill up in it each time the player asks for one, and a skill is known once or not at all. An NPC gets the skills it teaches, in a set as well. The reach to talk to an NPC moves into the world from the server, as learning needs it too: four metres, from the middle of an NPC, as you'd talk to someone. Use skill's comment changes, as a player uses a skill if they know it and it's ready, where it used to be once they'd reached its level. Then learn: the NPC, a trainer in this land, teaches the player the skill, or the error says why not, in words for the player. Each land counts its NPCs from one, which is why a land's world does the teaching. The error is an absl status, as everywhere in our code, and the server is going to send its message to the player as it is, so it's written for them. In world dot c c, string and str cat come in for the errors. Join gives each player the starting skills, on top of whatever they were saved with. That way, the database doesn't need to know which skills those are, and the starting skills can change in the content, like the starting weapon. Learn takes the player's id, and the two that a lesson names, the NPC's and the skill's, and finds each one, in the world's players and NPCs, and in the skill table. A single condition then checks all three, and that the NPC teaches the skill. If any of them isn't there, or the NPC doesn't teach the skill, the error says that nobody here teaches it, which covers a guard as well as an id that isn't there. Then the player has to stand within the talk reach of the trainer, which we measure on the ground, or they're too far away to learn from it, with its name. The next error names the skill, so we take its name from the skills file. A skill the player knows already is an error too, so that a second click says why nothing happened. Otherwise, the player knows the skill from now on. Further down, usable checks that the player knows a skill, where it checked the level they'd reached, as a player learns a skill from its level on, and once they know it, they can use it. In NPCs dot c c, which spawns a land's NPCs from the content, each NPC gets what its kind teaches, as a set made from the kind's list. In server dot c c, the talk reach goes, and talking to the storyteller uses the world's. In world test dot c c, the test that used skills from their level is about the skills players know now. The second player joins knowing the kick, and with a hundred experience, so their kick still does five more for level two, and the comment says that the second player knows the kick, and the first doesn't. A new test, learns skills from a trainer from their level, has a world like the test before it, made with a terrain and the test's sword, and then its kick. It has a trainer three metres along x, teaching the test's kick, and three players. The trainer is a plain NPC that the test spawns, with a name, a place and what it teaches, as learning reads nothing else of an NPC. The first player is at level two, the second at level one, and the third at level two, two metres the other way, which is five metres from the trainer. At level two, three metres from the trainer, the first player learns the kick, once, and can use it straight away. So asking again gets the error that they know it, word for word. Then they use the kick, and a tick later, the world has them in the skill's motion, which a player without it wouldn't be. The second player, at level one, is refused, with the level they can learn it from, and doesn't know the kick afterwards. And the third player is too far away to learn from the trainer. The fuzzer from episode twenty-eight plays with whatever a client could send, so its world gets the Meadows' NPCs too, from NPCs dot h, with the Meadows' trainer among them. A new case asks one of them for a skill, taking the NPC and the skill from the fuzzer's bytes, with ids past the ones there are. It ignores the status that comes back, as the fuzzer checks what the world keeps true. The README's section on skills says that players learn them from a trainer now, and a new section on learning skills says what learn checks. It also says that each player joins with the starting skills, so that the database doesn't need to know which they are.
14:52 We run the world's new test, for learning from a trainer, with grep leaving out gtest's banners, so that what fails fits on the screen. And it fails, on two of its checks. Learn should have refused the player at level one, with the level they can learn the kick from, and it gave back an empty message, which is what an ok status has. So the player at level one knows the kick now. We took the level out of usable and didn't put it into learn, so any player who stands next to a trainer learns each of its skills, at any level.
15:26 In learn, the skill's name is all we take from the skill, before checking whether the player knows it already. So after the name, we take the skill's level, and compare it with the player's, which comes from their experience. A player below it gets an error with the level they can learn the skill from, which is the message the test expects. So the level that usable no longer checks is checked here, once, when the player learns the skill.
15:52 The world's tests pass now, the one for learning among them, so a trainer teaches a player who's close enough, and has reached the skill's level.
Over the wire
16:02 In nanommo dot proto, a lesson gets a message, learn, and above it, we write what it is: a skill to learn from a trainer, by the NPC's id and the skill's. So its two fields are numbers, the NPC first and then the skill. Input gets it as field eight, after the skill to use, so the client sends a lesson with what it sent last, like a line of chat. And the world gets the skills you know, by their ids, in a repeated field. Proto three packs a repeated number into a single length and the values, so the ids take a byte each while they're under a hundred and twenty-eight. In server dot h, the server gets a learn of its own, next to talk, and in server dot c c, the message handler calls it when an input has a lesson in it. Learn finds the player's land, as the NPC's id is the land's own, and has the land teach them. A refusal goes back to the player through notify, as a line in the chat from nobody, like the storyteller's refusals. Absl's message is a string view, so we make a string of it for notify. A lesson that works goes into the server's log, with the player's name and the skill's id. In the broadcast, each player's own part gets the skills they know, after their cooldowns. The skills cost a few bytes a tick, and the client has the server's skills each tick, without a message of its own for each lesson. On the client's side, parts dot h keeps the skills next to the cooldowns, and parts dot c c makes room for them in each datagram. Each part of a world leaves room for the fields that aren't players or monsters, so that a full part with them stays under twelve hundred bytes. The room grows by the skills the client knows, a byte each, after two for the field, which are its tag and its length, so that's eleven more bytes, for the nine skills a hotbar holds. Add takes the skills from each part, like the cooldowns, as each part ends with what's the player's own. And world puts them back into the world it hands the client. Parts is where episode thirty-one's cooldowns went missing, as it keeps the player's own fields one by one, so the skills get a line of their own in both places. The parts test that keeps your cooldowns keeps your skills too, one and two, and expects them back in that order. And the README says how a lesson goes over the wire, and how a refusal comes back, as a line in the chat. Its section on UDP says that the parts leave room for the skills too.
18:27 The parts tests pass, so the skills a player knows make it through the parts, along with the cooldowns.
A window to learn in
18:36 The hotbar writes Font Awesome's icons out as UTF-8, and the trainer's window is going to show them too. So the hotbar's icon function moves into a header, icon dot h, with its guard, and with c std int and string, for the code point and the text. Icon is inline, like our test helpers, so that the two files that include it don't each define it for the linker. A Font Awesome icon, by its code point, as text for ImGui. The icons are in Unicode's private use area, between U plus E zero zero zero and U plus F eight F F, which UTF-8 writes in three bytes. The first byte takes the top four bits, and the other two take six each, as they did in the hotbar. Then the trainer's window, in trainer window dot h. Its standard headers are c std int for the ids, optional for what build returns, string for the name, utility for move, and vector for the skills it teaches. Then the animator, for the figures, the skills, for their names and icons, and our protocol, for the lesson it hands back. Like the talk window, it's a class, as it keeps which trainer it's showing from one frame to the next. It's a window with the skills a trainer teaches, which opens when the player clicks the trainer. Main makes a single one of these, and opens it for whichever trainer is clicked. It takes the skill table, by value, and moves it in, so it keeps a copy of its own, and it's explicit, like our other constructors with a single argument. Open takes the trainer's id, its name for the title, and the skills it teaches. Then build, which takes the figures, for our level, and the world, for the skills we know. It draws the window while it's open: each skill, with a button to learn it, once the player has reached its level. It returns the skill the player asks for, as a lesson, or nothing, which is most frames. It keeps the skills, the trainer's id as an optional, set while the window is open, its name, and the ids of what it teaches, which open fills in, and build reads. In trainer window dot c c, imgui comes after its header, then algorithm for find, and c std int, optional, string, utility and vector, the standard headers its header has too, as each file includes what it uses. And icon dot h, for the skills' icons. The buttons start two hundred points from the window's left, so that they line up, whatever the skills' names. Open takes the trainer's id, its name and its skills, and keeps the id, and moves the name and the skills in, as it took them by value. Build is called each frame, from main, whether the window is open or not, like the talk window's. So it draws nothing while there's no trainer. Our level comes with our figure, so we find ours, and take a level of zero until it's there. The window goes in the middle, seven tenths of the way down, a little higher than the talk window, as it's taller, and it's three hundred and twenty points wide. Like the talk window, it has a close button in its title bar, and doesn't resize or collapse. The trainer says, I can teach you these, once you're ready for them, with a line under it. Then a row for each skill it teaches. Each one is in our skills file, as check content made sure when the client read its content, so we find it by its id, for its name and its icon. Each row lines its text up with the button next to it, and writes the skill's icon and its name. Then, at the buttons' column, comes one of three things. A skill in our world's skills is known, in grey, one we haven't reached the level of says the level it's from, and otherwise, a learn button. A click on it makes the lesson, with the trainer's id and the skill's. The window stays open after a lesson, so that its button turns into known once the server has taught us, and our world says so, and closing it forgets the trainer. The client's library gets trainer window dot c c. A figure gets the skills an NPC teaches, by their ids, when it's a trainer, which the animator copies from the NPC's kind, like whether it talks. Picking takes an NPC the player can talk to or learn from now, so it skips an NPC that neither talks nor teaches. The picking test picks the nearest NPC that talks or teaches now, and its figures lose their const, as the test adds one. It's a fourth NPC, six metres ahead, which doesn't talk. A trainer is picked too, though it doesn't talk, as this one teaches a skill, so the trainer at six metres comes before the NPC at seven that talks. In main dot c c, we include the window's header, and make one, with the content's skills, next to the talk window. A click on an NPC that talks opens the talk window, as before, and a click on one that doesn't opens the trainer's, with what it teaches. In the GUI, after the talk window, the trainer's is drawn each frame. It gets our figures, for our level, and our world, for the skills we know. And a lesson goes to the server in a copy of the input we sent last, like a story's words. In hotbar dot h, build draws how long until a skill is ready, or, while the player doesn't know it, the level it needs, or learn once they've reached it. In hotbar dot c c, icon dot h takes the place of the hotbar's own icon function, which goes, comment and all. Each slot finds out whether we know its skill, from the world's skills, and greys out a skill we don't know, where it greyed out one we hadn't reached. Its tooltip says where to learn it, from a trainer, at its level, and how long it takes to be ready again, in seconds. The slot under the icon used to have the level a skill needed, in a label along its bottom, while we hadn't reached it. Now under a skill we don't know, the slot says learn, once we've reached its level, or the level otherwise, so that the bar says where each skill stands. The README's controls say to click the trainer, the silver one, to learn skills. Its section on learning skills says what the trainer's window shows, and that it stays open, so that its buttons turn into known once the server has taught you. And its section on the hotbar says what a skill you don't know yet looks like.
24:47 Alice starts at level four, which I've set in the database, and clicking the trainer opens its window, with a learn button for each of its skills. She learns the kick, which turns into known in the window, and lights up on the hotbar, so on two, she kicks, and the slot counts down its cooldown. Let's log out, and log back in, with alice's name still in the form. The kick is gone: the window offers it again, and the hotbar says learn. The server knew the kick while alice played, and nothing kept it once she left, so next, we save what players learn, with the rest of the player.
Saving what players learn
25:22 The skills go where the rest of the player goes, which we built in episode thirteen. After each tick, the server saves the players that changed, into a hash in Redis for each player, which gets the skills as one field, the ids with commas between them. Our saver, on a timer of five seconds, takes the unsaved players to MySQL, where the skills go into a table of their own, a row for each skill a player knows. A column with the list in it would be quicker to write. A row each lets MySQL check each one: a primary key keeps a skill from being there twice, a foreign key ties it to its player, and a query can find, say, who knows the whirlwind. And when a player joins, the server reads them from MySQL, then from Redis, whose copy wins when it has one, as it's newer.
26:09 The table comes from a migration, V six, in our db folder, which Flyway runs from Compose like the others. Player skills has the account's id, the world, and the skill's id, as a player in each world learns their own. The primary key is all three, so a player knows each skill once, and MySQL finds a player's skills by the first two, which lead the key. And the foreign key points at the player's row in players, with on delete cascade, so deleting an account deletes its players, and their skills with them. A new table leaves the servers from before it working, as they don't know it's there. In players dot h, set comes in, and the progress we save gets the skills, as a set, like the world's. The server compares each player's progress with the one it saved last, field by field, so a lesson is a change that gets saved, with no more code from us. In server dot c c, progress of copies the player's skills into both of its progresses, the living and the dead. In players dot c c, string view, str join and str split come in for the list in Redis. Load reads a player from MySQL first, and then from Redis, whose copy wins when it has one, as it's newer. So after the player's row, it reads their skills from MySQL, a row each, with the account and the world in the where clause, which is the start of the table's key. If MySQL fails, the load fails with it, as it does for the player's row. Otherwise, we collect each row's skill in a list, and join the rows the way Redis keeps them, with commas, so that the code after it reads either one the way it reads the rest. Redis's hash gets read for its skills field too, and when the hash is there, it wins. Then we split the list at its commas, skipping empty pieces, as a player with no skills has an empty field, and a piece that isn't a number is an error, like the other fields. A hand edit in Redis could leave something there that isn't a number, and the player would otherwise load with a skill that doesn't exist. Each id goes into the player's skills, as a number. The world adds the starting skills when the player joins, so a player saved before this step, with no skills field, still has the thrust. Save writes each changed player's hash with an H set, like before. With one more field, clang format lays the list out as two columns, the names and their values, a field to a line. The world and the account come first, so that the saver can tell whose hash it is, and each value goes in as a string, as Redis keeps strings. Then the place, x and z, the experience, the health, the land and the weapon. A hash keeps each field under its name, so the server and the saver read the fields they want, by name, and a field added later doesn't move the others. So the skills go last, as a string with commas between the ids, which str join writes out of the set for us. And the server sends all the players' writes in one pipeline, so that saving a few hundred players after a tick still takes a single round trip to Redis. In flush, the saver reads the skills from each hash too, after the weapon. It already builds an insert into players, five hundred players at a time, from the values in the hashes. Next to it, we build a second insert, into player skills, with its values. Each hash gets a name, as we use it a few times now. A hash from before worlds has no skills, so its values go in as they did. For the others, the players insert takes the hash without its last field, and the comment over it says why: the skills go into a table of their own, a row each. So each skill in that field adds a row: three question marks on the skills insert, with a comma before each row but the first, and the account, the world and the skill on its values. After the players insert, the skills insert runs, when there are skills, and we write why it's built like this over it. A skill once learned stays learned, so each flush inserts each skill a player knows, and IGNORE skips the ones MySQL has already, and those of a player whose row it refused. So the saver doesn't have to work out which skills are new. The skills insert's result takes the players insert's place, so a failure of either one puts the players back in the set for the next try, as before. In the players test, the later progress that load reads back knows skills one and three, and the test expects them after the load, from Redis. The test that flushes the players to MySQL saves skills one and two in Frost. After the flush, with Redis's copy gone, the skills come back from MySQL's rows. So the test deletes the hash and loads the player again, which has to work, and expects skills one and two. And the README's section on saving players has the skills field in the hash, and the player skills table, with why the saver inserts each skill each time.
30:56 Alice is at level four again, and doesn't know the kick yet, as this is a fresh database, and she learns the kick from the trainer. Then she logs out from the game menu, and back in. This time the kick is still known, in the window and on the hotbar, as the server saved it into Redis after the tick she learned it in, and read it back when she joined. So on two, she kicks, and the cooldown starts.
31:23 The players tests need MySQL and Redis, so Compose starts what they depend on, with Flyway running V six before them. They pass, so the skills go into Redis, come back out, reach their table, and come back from it.
Bots that use their skills
31:40 Our bots wander, chat and ask for stories, and none of them has used a skill yet, so the worlds we send them have no cooldowns in them, and our load tests leave out bytes that players' worlds carry. In bot dot h, a bot gets the skills it knows, from its world, as a vector, so that it can pick one at random. In bot dot c c, a constant, a quarter, says how often a bot uses one. While it wanders around somewhere, a bot uses a skill it knows with one direction in four, as players do in a fight. In receive, after it reads the players, each world the bot receives lists the skills it knows, so the bot keeps a copy. So while the bot stays somewhere, each new direction may carry a skill, if it knows any, picked at random from the ones it knows. Our bots stay at level one, and nobody teaches them, so that's the thrust, which holds a bot for three seconds and cools down for eight. And the README says what the bots do with their skills.
Wrap-up
32:38 So, today we fixed what CI had been failing on. We opened the coverage report's folders to other users, had the tidy stage write the missing header, and checked an optional before reading it, and a private copy keeps its docs off Pages. Then the content got a trainer, which teaches the kick, second wind and the whirlwind, and each player starts with the thrust. The world teaches a skill to a player who stands near the trainer, has reached its level and doesn't know it yet, and otherwise tells them why not. The level was the check a test caught us forgetting. The client got a window to learn in, and the hotbar greys out what you don't know yet. What a player learns is saved with the rest of them, in Redis after each tick, and in a table of its own in MySQL, from migration V six. And the bots use the skills they know, which is the thrust, for now.
Playing it
34:19 Let me know in the comments what you'd like to see next. And if you want to follow along with the series, subscribe to the channel. I hope you find this useful, and thanks for watching.