game.c (12881B)
1 #define _POSIX_C_SOURCE 200809L 2 #include "game/game.h" 3 #include "engine/save.h" 4 #include "game/replay.h" 5 6 #include <stdio.h> 7 #include <stdlib.h> 8 #include <string.h> 9 #include <unistd.h> 10 11 #include "config.h" 12 13 #define CHECK(test) do { if (!(test)) { \ 14 fprintf(stderr, "game smoke failed at line %d: %s\n", __LINE__, #test); \ 15 goto fail; } } while (0) 16 17 static int 18 ph_timing_smoke(void) 19 { 20 static const PhTileDef tiles[] = { { .symbol = '.' } }; 21 static const PhEntityDef actors[] = { 22 { .id = 1, .kind = PH_ENTITY_PLAYER, .move_speed = 1, .stats = { .max_hp = 1 } }, 23 { .id = 2, .kind = PH_ENTITY_NPC, .move_speed = 1, .stats = { .max_hp = 1 } }, 24 }; 25 unsigned char map[20 * 8] = { 0 }; 26 PhArea area = { .width = 20, .height = 8, .tiles = map, 27 .tile_defs = tiles, .tile_def_count = 1 }; 28 PhWorldCatalog catalog = { .entities = actors, .entity_count = 2 }; 29 PhWorld reference = { 0 }, world = { 0 }; 30 PhClock clock = { 0 }; 31 const int frames[] = { 60, 120, 288, 200 }; 32 int run, frame, ticks, total = 0, moves, reference_moves = 0; 33 34 /* Fractional ticks survive short frames; huge stalls cannot overflow or 35 * leave a backlog to execute on subsequent frames. */ 36 for (frame = 0; frame < 1000; ++frame) 37 total += ph_clock_steps(&clock, 1000000, 60, 4); 38 if (total != 60 || clock.credit != 0 || 39 ph_clock_steps(&clock, UINT64_MAX, 60, 4) != 4 || clock.credit != 0 || 40 ph_clock_steps(&clock, 0, 60, 4) != 0) return 1; 41 42 for (run = 0; run < 4; ++run) { 43 PhGameMode mode = PH_GAME_WORLD; 44 PhGameContext context = { .world = &world, .mode = &mode }; 45 uint64_t previous = 0; 46 47 if (ph_world_init(&world, area, 1, catalog) < 0) return 1; 48 if (ph_world_spawn_entity(&world, 1, (PhVec2){ 24, 24 }, 0, 1) < 0 || 49 ph_world_spawn_entity(&world, 2, (PhVec2){ 200, 88 }, 4, 1) < 0) return 1; 50 ph_world_set_player(&world, 0); 51 total = moves = 0; 52 for (frame = 1; frame <= frames[run]; ++frame) { 53 uint64_t now = (uint64_t)frame * 2000000000 / frames[run]; 54 55 if (run == 3 && frame % 2) now -= 7000000; 56 57 ticks = ph_game_steps(&context, now - previous); 58 previous = now; 59 total += ticks; 60 while (ticks-- > 0) { 61 PhWorldEvent event; 62 63 ph_world_tick(&world, (PhInput){ .move_x = 1 }, 1.0f / PH_SIMULATION_HZ); 64 while (ph_world_take_event(&world, &event)) 65 if (event.kind == PH_WORLD_EVENT_MOVE) ++moves; 66 } 67 } 68 if (total != 2 * PH_SIMULATION_HZ || context.clock.credit != 0 || moves == 0) 69 return 1; 70 if (!run) { reference = world; reference_moves = moves; } 71 else if (memcmp(&reference, &world, sizeof(world)) || moves != reference_moves) 72 return 1; 73 /* No owned area buffers or captured areas exist in this fixture. */ 74 ph_world_destroy(&world); 75 } 76 return 0; 77 } 78 79 static int 80 ph_replay_smoke(void) 81 { 82 PhWorld worlds[2] = { 0 }; 83 PhBattle battles[2] = { 0 }; 84 PhSpellBook spells[2] = { ph_player_spellbook, ph_player_spellbook }; 85 PhInventoryUi inventory[2] = { 0 }; 86 PhSpellbookUi spellbook[2] = { 0 }; 87 PhShopUi shop[2] = { 0 }; 88 PhOptionsUi options[2] = { 0 }; 89 PhStartUi start[2] = { 0 }; 90 PhGameMode modes[2] = { PH_GAME_WORLD, PH_GAME_WORLD }; 91 PhGameContext games[2]; 92 FILE *file = tmpfile(); 93 unsigned int seed = 12345, loaded_seed = 0; 94 uint64_t ticks = 0, replay_ticks = 0; 95 int i, j, saw_battle = 0, result = 1; 96 PhAction action; 97 PhInput input; 98 99 CHECK(file); 100 CHECK(ph_replay_header(file, &seed, 1) == 0); 101 for (i = 0; i < 2; ++i) { 102 games[i] = (PhGameContext){ .world = &worlds[i], .battle = &battles[i], 103 .spells = &spells[i], .inventory = &inventory[i], .spellbook = &spellbook[i], 104 .shop = &shop[i], .options = &options[i], .start = &start[i], .mode = &modes[i] }; 105 CHECK(ph_world_start(&worlds[i], &ph_game_world) == 0); 106 worlds[i].rng_state = seed; 107 /* Regression fixture: contact a slime immediately, then resolve real 108 * seeded attacks through the same input actions as the live host. */ 109 for (j = 0; j < worlds[i].entity_count; ++j) 110 if (worlds[i].entities[j].type_id == PH_DEF_SLIME) break; 111 CHECK(j < worlds[i].entity_count); 112 worlds[i].encounter_index = j; 113 } 114 for (i = 0; i < 1200; ++i) { 115 PhWorldEvent event; 116 117 action = i == 0 ? PH_ACTION_CHARACTER : i == 5 ? PH_ACTION_BACK : PH_ACTION_ACCEPT; 118 /* Several menu actions share one simulation tick while paused. */ 119 if (i > 0 && i < 5) action = PH_ACTION_DOWN; 120 CHECK(ph_replay_write(file, PH_REPLAY_ACTION, action, (PhInput){ 0 }) == 0); 121 CHECK(!(ph_game_input(&games[0], action) & (PH_GAME_REQUEST_NEW | PH_GAME_REQUEST_EXIT))); 122 if (games[0].menu == PH_MENU_NONE && modes[0] != PH_GAME_VICTORY && modes[0] != PH_GAME_OVER) { 123 input = (PhInput){ .move_x = i < 600 ? 1 : -1, .interact = i % 17 == 0 }; 124 CHECK(ph_replay_write(file, PH_REPLAY_TICK, PH_ACTION_NONE, input) == 0); 125 ph_game_update(&games[0], input, 1.0f / PH_SIMULATION_HZ, NULL); 126 ph_game_interaction(&games[0]); 127 ++ticks; 128 } 129 if (modes[0] == PH_GAME_BATTLE) saw_battle = 1; 130 ph_battle_take_event(&battles[0]); 131 spellbook[0].spell_used = 0; 132 while (ph_world_take_event(&worlds[0], &event)) { } 133 } 134 CHECK(saw_battle && ticks > 100 && worlds[0].rng_state != seed); 135 CHECK(ph_replay_write(file, PH_REPLAY_END, PH_ACTION_NONE, (PhInput){ 0 }) == 0); 136 rewind(file); 137 CHECK(ph_replay_header(file, &loaded_seed, 0) == 0 && loaded_seed == seed); 138 CHECK(ph_replay_run(file, &games[1], &replay_ticks) == 0 && replay_ticks == ticks); 139 CHECK(worlds[0].rng_state == worlds[1].rng_state && worlds[0].area_id == worlds[1].area_id); 140 CHECK(worlds[0].entity_count == worlds[1].entity_count); 141 CHECK(!memcmp(worlds[0].entities, worlds[1].entities, sizeof(worlds[0].entities))); 142 CHECK(!memcmp(worlds[0].inventory, worlds[1].inventory, sizeof(worlds[0].inventory))); 143 CHECK(!memcmp(&battles[0], &battles[1], sizeof(battles[0]))); 144 CHECK(modes[0] == modes[1] && games[0].menu == games[1].menu); 145 CHECK(!memcmp(&spells[0], &spells[1], sizeof(spells[0]))); 146 /* Missing terminator, partial record, invalid input, and trailing bytes. */ 147 CHECK(ph_replay_read(file, &action, &input) == -1); 148 CHECK(ftruncate(fileno(file), 17) == 0); 149 CHECK(fseek(file, 16, SEEK_SET) == 0); 150 CHECK(ph_replay_read(file, &action, &input) == -1); 151 rewind(file); 152 CHECK(fputc(PH_REPLAY_TICK, file) != EOF && fputc(3, file) != EOF); 153 CHECK(fputc(1, file) != EOF && fputc(0, file) != EOF); 154 rewind(file); 155 CHECK(ph_replay_read(file, &action, &input) == -1); 156 rewind(file); 157 CHECK(fputc(PH_REPLAY_END, file) != EOF); 158 rewind(file); 159 CHECK(ph_replay_read(file, &action, &input) == -1); 160 CHECK(ph_replay_header(file, &loaded_seed, 0) == -1); 161 result = 0; 162 fail: 163 if (file) fclose(file); 164 for (i = 0; i < 2; ++i) ph_world_destroy(&worlds[i]); 165 return result; 166 } 167 168 int 169 main(void) 170 { 171 PhWorld world = { 0 }, loaded = { 0 }; 172 PhBattle battle = { 0 }; 173 PhSpellBook spells = ph_player_spellbook, loaded_spells = ph_player_spellbook; 174 PhGameMode mode = PH_GAME_WORLD; 175 PhInventoryUi inventory = { 0 }; 176 PhSpellbookUi spellbook = { 0 }; 177 PhShopUi shop = { 0 }; 178 PhOptionsUi options = { .music_enabled = 1, .effects_enabled = 1 }; 179 PhStartUi start = { .can_continue = 1 }; 180 PhGameContext game = { .world = &world, .battle = &battle, .mode = &mode, 181 .spells = &spells, .inventory = &inventory, .spellbook = &spellbook, 182 .shop = &shop, .options = &options, .start = &start, .menu = PH_MENU_START }; 183 char directory[] = "/tmp/ph-game-XXXXXX"; 184 char save_path[128], temp_path[132]; 185 PhEntity *player; 186 PhVec2 position; 187 int i, enemy = -1, potion = -1, result = 1; 188 float timer; 189 190 if (ph_replay_smoke()) return 1; 191 if (ph_timing_smoke()) { 192 fputs("fixed-timestep smoke failed\n", stderr); 193 return 1; 194 } 195 if (!mkdtemp(directory)) return 1; 196 snprintf(save_path, sizeof(save_path), "%s/checkpoint.sav", directory); 197 snprintf(temp_path, sizeof(temp_path), "%s.tmp", save_path); 198 CHECK(ph_world_start(&world, &ph_game_world) == 0); 199 player = &world.entities[world.player_index]; 200 position = player->pos; 201 ph_game_update(&game, (PhInput){ .move_x = 1 }, 0.05f, save_path); 202 CHECK(player->pos.x == position.x && player->pos.y == position.y); 203 CHECK(ph_game_input(&game, PH_ACTION_ACCEPT) == 0 && game.menu == PH_MENU_NONE); 204 CHECK(ph_game_steps(&game, UINT64_MAX) == 0); 205 CHECK(ph_game_steps(&game, 1000000) == 0 && game.clock.credit > 0); 206 207 /* Backspace stays at the root; Escape opens Options and pauses simulation. */ 208 ph_game_input(&game, PH_ACTION_BACK); 209 CHECK(game.menu == PH_MENU_NONE); 210 ph_game_input(&game, PH_ACTION_CANCEL); 211 CHECK(game.menu == PH_MENU_MAIN); 212 CHECK(ph_game_steps(&game, UINT64_MAX) == 0 && game.clock.credit == 0); 213 ph_game_update(&game, (PhInput){ .move_x = 1 }, 0.05f, save_path); 214 CHECK(player->pos.x == position.x && player->pos.y == position.y); 215 ph_game_input(&game, PH_ACTION_ACCEPT); 216 ph_game_input(&game, PH_ACTION_ACCEPT); 217 CHECK(!options.music_enabled && options.effects_enabled); 218 CHECK(ph_game_input(&game, PH_ACTION_DOWN) & PH_GAME_MENU_SOUND); 219 ph_game_input(&game, PH_ACTION_ACCEPT); 220 CHECK(!options.music_enabled && !options.effects_enabled); 221 ph_game_input(&game, PH_ACTION_BACK); 222 CHECK(game.menu == PH_MENU_MAIN && options.mode == PH_OPTIONS_ROOT); 223 ph_game_input(&game, PH_ACTION_CANCEL); 224 CHECK(game.menu == PH_MENU_NONE); 225 CHECK(ph_game_steps(&game, UINT64_MAX) == 0 && game.clock.credit == 0); 226 CHECK(ph_game_steps(&game, 1000000000 / PH_SIMULATION_HZ + 1) == 1); 227 228 /* Use an item through the same actions as the SDL host. */ 229 for (i = 0; i < world.content.item_count; ++i) 230 if (world.content.items[i].id == PH_ITEM_HEALING_POTION) potion = i; 231 CHECK(potion >= 0); 232 memset(world.inventory, 0, sizeof(world.inventory)); 233 world.inventory[potion] = 2; 234 player->vitals.hp = 1; 235 ph_game_input(&game, PH_ACTION_INVENTORY); 236 CHECK(game.menu == PH_MENU_INVENTORY && inventory.selected == potion); 237 ph_game_input(&game, PH_ACTION_ACCEPT); 238 CHECK(inventory.mode == PH_INVENTORY_ACTIONS && inventory.option == PH_INVENTORY_USE); 239 ph_game_input(&game, PH_ACTION_ACCEPT); 240 CHECK(player->vitals.hp > 1 && world.inventory[potion] == 1); 241 ph_game_input(&game, PH_ACTION_ACCEPT); 242 ph_game_input(&game, PH_ACTION_CANCEL); 243 CHECK(inventory.mode == PH_INVENTORY_BROWSE && game.menu == PH_MENU_INVENTORY); 244 ph_game_input(&game, PH_ACTION_BACK); 245 CHECK(game.menu == PH_MENU_NONE); 246 247 ph_game_input(&game, PH_ACTION_SPELLBOOK); 248 CHECK(game.menu == PH_MENU_SPELLBOOK); 249 ph_game_input(&game, PH_ACTION_ACCEPT); 250 CHECK(spellbook.mode == PH_SPELLBOOK_ACTIONS); 251 ph_game_input(&game, PH_ACTION_BACK); 252 ph_game_input(&game, PH_ACTION_BACK); 253 CHECK(game.menu == PH_MENU_NONE); 254 world.interaction_id = PH_INTERACTION_MIRA; 255 ph_game_interaction(&game); 256 CHECK(game.menu == PH_MENU_SHOP && world.interaction_id == 0); 257 ph_game_input(&game, PH_ACTION_ACCEPT); 258 CHECK(shop.mode == PH_SHOP_BUY); 259 ph_game_input(&game, PH_ACTION_BACK); 260 ph_game_input(&game, PH_ACTION_BACK); 261 CHECK(game.menu == PH_MENU_NONE); 262 263 /* Contact enters combat and saves a loadable pre-combat checkpoint. */ 264 for (i = 0; i < world.entity_count; ++i) 265 if (world.entities[i].type_id == PH_DEF_SLIME) { enemy = i; break; } 266 CHECK(enemy >= 0); 267 world.encounter_index = enemy; 268 ph_game_update(&game, (PhInput){ 0 }, 0.0f, save_path); 269 CHECK(mode == PH_GAME_TRANSITION && world.combat_active); 270 CHECK(ph_save_load(save_path, &loaded, &ph_game_world, &loaded_spells) == 1); 271 CHECK(!loaded.combat_active); 272 ph_game_update(&game, (PhInput){ 0 }, PH_BATTLE_TRANSITION_SECONDS, save_path); 273 CHECK(mode == PH_GAME_BATTLE); 274 ph_game_input(&game, PH_ACTION_BACK); 275 CHECK(game.menu == PH_MENU_NONE); 276 ph_game_input(&game, PH_ACTION_ACCEPT); 277 CHECK(battle.phase == PH_BATTLE_TARGET); 278 ph_game_input(&game, PH_ACTION_CANCEL); 279 CHECK(battle.phase == PH_BATTLE_COMMAND && game.menu == PH_MENU_NONE); 280 ph_game_input(&game, PH_ACTION_ACCEPT); 281 ph_game_input(&game, PH_ACTION_ACCEPT); 282 CHECK(battle.phase == PH_BATTLE_PLAYER_ACTION); 283 timer = battle.timer; 284 ph_game_input(&game, PH_ACTION_CANCEL); 285 ph_game_update(&game, (PhInput){ 0 }, 1.0f, save_path); 286 CHECK(game.menu == PH_MENU_MAIN && battle.timer == timer); 287 ph_game_input(&game, PH_ACTION_BACK); 288 /* Complete a resolved lethal action, avoiding random damage in this flow test. */ 289 world.entities[enemy].vitals.hp = 0; 290 battle.resolved = 1; 291 CHECK(ph_game_update(&game, (PhInput){ 0 }, PH_BATTLE_ACTION_SECONDS, save_path)); 292 CHECK(mode == PH_GAME_VICTORY && !world.combat_active); 293 ph_game_input(&game, PH_ACTION_ACCEPT); 294 CHECK(mode == PH_GAME_WORLD && game.menu == PH_MENU_NONE); 295 296 ph_game_input(&game, PH_ACTION_CANCEL); 297 ph_game_input(&game, PH_ACTION_DOWN); 298 CHECK(ph_game_input(&game, PH_ACTION_ACCEPT) & PH_GAME_REQUEST_EXIT); 299 game.menu = PH_MENU_START; 300 start.can_continue = 0; 301 CHECK(ph_game_input(&game, PH_ACTION_ACCEPT) & PH_GAME_REQUEST_NEW); 302 puts("game smoke: seeded replay, fixed timing, actions, menus, pause, checkpoint and battle flow passed"); 303 result = 0; 304 fail: 305 ph_world_destroy(&world); 306 ph_world_destroy(&loaded); 307 remove(save_path); 308 remove(temp_path); 309 rmdir(directory); 310 return result; 311 }