phantasia

Phantasia - 2D SDL3 RPG prototype.
git clone git://git.beep.wimdupont.com/phantasia.git
Log | Files | Refs | README | LICENSE

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 }