phantasia

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

smoke.c (28589B)


      1 #include "engine/area.h"
      2 #include "engine/world.h"
      3 #include "engine/battle.h"
      4 #include "engine/audio.h"
      5 #include "engine/save.h"
      6 #include "game/content.h"
      7 #include "config.h"
      8 
      9 #include <stdio.h>
     10 #include <limits.h>
     11 #include <string.h>
     12 
     13 #define LEN(a) (sizeof(a) / sizeof(0[a]))
     14 
     15 static int
     16 ph_event_overflow_test(void)
     17 {
     18 	PhWorld world = { 0 };
     19 	PhWorldEvent event;
     20 	int i;
     21 
     22 	for (i = 0; i < PH_MAX_WORLD_EVENTS; ++i)
     23 		if (ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, (PhVec2){ 0 }, i) < 0) return 1;
     24 	if (world.event_dropped || ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE,
     25 			(PhVec2){ 0 }, -1) != -1 || world.event_dropped != 1) return 1;
     26 	for (i = 0; i < PH_MAX_WORLD_EVENTS; ++i)
     27 		if (!ph_world_take_event(&world, &event) || event.entity_index != i) return 1;
     28 	if (world.event_dropped != 1 || ph_world_take_event(&world, &event)) return 1;
     29 	if (ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, (PhVec2){ 0 }, 17) < 0 ||
     30 			!ph_world_take_event(&world, &event) || event.entity_index != 17) return 1;
     31 	world.event_count = PH_MAX_WORLD_EVENTS;
     32 	world.event_dropped = UINT_MAX;
     33 	ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, (PhVec2){ 0 }, -1);
     34 	return world.event_dropped != UINT_MAX;
     35 }
     36 
     37 static unsigned int
     38 ph_world_event_mask(PhWorld *world)
     39 {
     40 	PhWorldEvent event;
     41 	unsigned int mask = 0;
     42 
     43 	while (ph_world_take_event(world, &event)) mask |= 1u << event.kind;
     44 	return mask;
     45 }
     46 
     47 static int
     48 ph_area_catalog_test(void)
     49 {
     50 	unsigned char tiles[] = { 0, 0, 0, 0 };
     51 	PhAreaMarker markers[] = {
     52 		{ '@', 0, 0 },
     53 		{ 'a', 1, 0 },
     54 		{ '1', 0, 1 },
     55 	};
     56 	const PhMarkerDef marker_defs[] = {
     57 		{ 7, '@', '.', PH_MARKER_PLAYER, 10, 0, 0, 0, 0 },
     58 		{ 7, 'a', '.', PH_MARKER_ENTITY, 20, 0, 3, 0, 0 },
     59 		{ 7, '1', '.', PH_MARKER_ITEM, 30, 2, 0, 0, 0 },
     60 	};
     61 	const PhAreaCatalog catalog = {
     62 		.markers = marker_defs,
     63 		.marker_count = (int)LEN(marker_defs),
     64 	};
     65 	const PhEntityDef entity_defs[] = {
     66 		{ .id = 10, .stats = { .max_hp = 10 }, .kind = PH_ENTITY_PLAYER },
     67 		{ .id = 20, .stats = { .max_hp = 5 }, .kind = PH_ENTITY_MONSTER },
     68 	};
     69 	const PhItemDef item_defs[] = { { .id = 30 } };
     70 	const PhWorldCatalog content = {
     71 		.entities = entity_defs,
     72 		.items = item_defs,
     73 		.entity_count = (int)LEN(entity_defs),
     74 		.item_count = (int)LEN(item_defs),
     75 	};
     76 	PhArea area = {
     77 		.name = "Catalog",
     78 		.width = 2,
     79 		.height = 2,
     80 		.tiles = tiles,
     81 		.markers = markers,
     82 		.marker_count = (int)LEN(markers),
     83 	};
     84 	PhWorld world;
     85 	int player = -1;
     86 	int entity_count;
     87 	int item_count;
     88 
     89 	if (ph_world_init(&world, area, 7, content) < 0 ||
     90 			ph_world_prepare_area(&world, &area, &catalog, 7, &player) < 0)
     91 		return 1;
     92 	if (player < 0 || world.entity_count != 2 || world.ground_item_count != 1 ||
     93 			world.entities[1].behavior.territory_radius != 3 ||
     94 			world.ground_items[0].amount != 2 || world.initialized_area_count != 1)
     95 		return 1;
     96 	entity_count = world.entity_count;
     97 	item_count = world.ground_item_count;
     98 	if (ph_world_prepare_area(&world, &area, &catalog, 7, &player) < 0 ||
     99 			world.entity_count != entity_count || world.ground_item_count != item_count)
    100 		return 1;
    101 	ph_world_destroy(&world);
    102 	return 0;
    103 }
    104 
    105 static int
    106 ph_movement_test(int open, int blocked)
    107 {
    108 	enum {
    109 		MAP_W = 9,
    110 		MAP_H = 5,
    111 		ROW = 2,
    112 		PLAYER_COL = 1,
    113 		MOB_COL = 6,
    114 		WALL_COL = 5,
    115 		TERRITORY = 2,
    116 		WANDER_ACTIONS = 20,
    117 	};
    118 	unsigned char tiles[MAP_W * MAP_H];
    119 	PhEntityDef entity_defs[] = {
    120 		{ .id = 1, .stats = { .max_hp = 10 }, .move_speed = 2,
    121 			.animation_frames = 4, .kind = PH_ENTITY_PLAYER },
    122 		{ .id = 2, .stats = { .max_hp = 10 }, .move_speed = 1,
    123 			.vision = 8, .aggressive = 1, .blocks_movement = 1,
    124 			.kind = PH_ENTITY_MONSTER },
    125 	};
    126 	const PhWorldCatalog content = {
    127 		.entities = entity_defs,
    128 		.entity_count = (int)LEN(entity_defs),
    129 	};
    130 	PhArea area = { .name = "Movement", .width = MAP_W, .height = MAP_H,
    131 		.tiles = tiles, .tile_defs = ph_tile_defs,
    132 		.tile_def_count = (int)LEN(ph_tile_defs) };
    133 	PhVec2 player_start = { PH_TILE_CENTER(PLAYER_COL), PH_TILE_CENTER(ROW) };
    134 	PhVec2 mob_start = { PH_TILE_CENTER(MOB_COL), PH_TILE_CENTER(ROW) };
    135 	float action_seconds = (float)PH_WORLD_ACTION_MILLISECONDS / 1000.0f;
    136 	const float frame_seconds = 0.05f;
    137 	PhWorld world;
    138 	PhWorldEvent event;
    139 	PhEntity *mob;
    140 	int player_index;
    141 	int mob_index;
    142 	int moved = 0;
    143 	int x;
    144 	int y;
    145 	int i;
    146 
    147 	memset(tiles, open, sizeof(tiles));
    148 	for (y = 0; y < area.height; ++y)
    149 		for (x = 0; x < area.width; ++x)
    150 			if (!x || !y || x == area.width - 1 || y == area.height - 1)
    151 				tiles[y * area.width + x] = (unsigned char)blocked;
    152 	if (ph_world_init(&world, area, 1, content) < 0 ||
    153 			(player_index = ph_world_spawn_entity(&world, 1, player_start, 0, 1)) < 0 ||
    154 			(mob_index = ph_world_spawn_entity(&world, 2, mob_start,
    155 				TERRITORY, 1)) < 0) return 1;
    156 	ph_world_set_player(&world, player_index);
    157 	mob = &world.entities[mob_index];
    158 	ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
    159 	if (!mob->motion.moving || mob->motion.target.x != PH_TILE_CENTER(MOB_COL - 1) ||
    160 			mob->motion.target.y != mob_start.y ||
    161 			!ph_world_take_event(&world, &event) ||
    162 			event.kind != PH_WORLD_EVENT_MOVE || event.entity_index != mob_index)
    163 		return 1;
    164 	ph_world_tick(&world, (PhInput){ 0 }, frame_seconds);
    165 	if (mob->pos.x >= mob_start.x) return 1;
    166 	ph_world_tick(&world, (PhInput){ .move_x = 1 }, frame_seconds);
    167 	if (!world.entities[player_index].motion.moving ||
    168 			world.entities[player_index].motion.target.x !=
    169 			PH_TILE_CENTER(PLAYER_COL + 1) ||
    170 			!ph_world_take_event(&world, &event) ||
    171 			event.kind != PH_WORLD_EVENT_MOVE || event.entity_index != player_index ||
    172 			ph_entity_animation_frame(&world.content.entities[0],
    173 				&world.entities[player_index]) != 0) return 1;
    174 	ph_world_tick(&world, (PhInput){ 0 }, frame_seconds);
    175 	if (ph_entity_animation_frame(&world.content.entities[0],
    176 			&world.entities[player_index]) <= 0) return 1;
    177 	world.entities[player_index].pos =
    178 		world.entities[player_index].motion.target = player_start;
    179 	world.entities[player_index].motion.moving = 0;
    180 	mob->pos = mob->motion.target = (PhVec2){
    181 		PH_TILE_CENTER(PLAYER_COL + 1), player_start.y };
    182 	mob->motion.moving = 0;
    183 	mob->motion.credit = 0.0f;
    184 	ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
    185 	if (world.encounter_index != mob_index) return 1;
    186 
    187 	world.entities[player_index].pos = player_start;
    188 	mob->pos = mob->motion.target = mob_start;
    189 	mob->behavior.last_seen = player_start;
    190 	mob->motion.moving = 0;
    191 	mob->motion.credit = 0.0f;
    192 	mob->behavior.chase_turns = 2;
    193 	world.encounter_index = -1;
    194 	tiles[ROW * area.width + WALL_COL] = (unsigned char)blocked;
    195 	ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
    196 	if (mob->motion.moving || mob->behavior.chase_turns != 1) return 1;
    197 
    198 	tiles[ROW * area.width + WALL_COL] = (unsigned char)open;
    199 	entity_defs[1].aggressive = 0;
    200 	mob->behavior.home = mob->pos = mob->motion.target = mob_start;
    201 	mob->behavior.territory_radius = 1;
    202 	mob->behavior.chase_turns = 0;
    203 	mob->motion.credit = 0.0f;
    204 	for (i = 0; i < WANDER_ACTIONS; ++i) {
    205 		float dx;
    206 		float dy;
    207 		float radius = (float)(mob->behavior.territory_radius * PH_TILE_SIZE);
    208 
    209 		ph_world_tick(&world, (PhInput){ 0 }, action_seconds);
    210 		dx = mob->pos.x - mob->behavior.home.x;
    211 		dy = mob->pos.y - mob->behavior.home.y;
    212 		if (dx != 0.0f || dy != 0.0f) moved = 1;
    213 		if (dx * dx + dy * dy > radius * radius) return 1;
    214 	}
    215 	return moved ? 0 : 1;
    216 }
    217 
    218 static int
    219 ph_area_state_scaling_test(void)
    220 {
    221 	enum { AREA_COUNT = 512 };
    222 	const PhEntityDef player_def = {
    223 		.id = 1, .stats = { .max_hp = 10 }, .kind = PH_ENTITY_PLAYER,
    224 	};
    225 	const PhWorldCatalog content = { .entities = &player_def, .entity_count = 1 };
    226 	PhWorld world;
    227 	PhArea area = { 0 };
    228 	int player;
    229 	int i;
    230 	int result = 1;
    231 
    232 	if (ph_world_init(&world, area, 1, content) < 0 ||
    233 			(player = ph_world_spawn_entity(&world, 1, (PhVec2){ 0 }, 0, 1)) < 0)
    234 		return 1;
    235 	ph_world_set_player(&world, player);
    236 	for (i = AREA_COUNT; i > 0; --i) {
    237 		world.area_id = i;
    238 		world.entities[world.player_index].area_id = i;
    239 		if (ph_world_capture_area(&world) < 0) goto done;
    240 	}
    241 	if (world.initialized_area_count != AREA_COUNT ||
    242 			world.area_state_capacity < AREA_COUNT) goto done;
    243 	for (i = 0; i < AREA_COUNT; ++i)
    244 		if (world.area_states[i].area_id != i + 1) goto done;
    245 	result = 0;
    246 done:
    247 	ph_world_destroy(&world);
    248 	return result;
    249 }
    250 
    251 static int
    252 ph_flash_delay_test(void)
    253 {
    254 	const PhEntityDef entity_defs[] = {
    255 		{ .id = 1, .stats = { .max_hp = 200, .max_mp = 100, .magic = 8,
    256 			.agility = 10, .accuracy = 100 }, .kind = PH_ENTITY_PLAYER },
    257 		{ .id = 2, .stats = { .max_hp = 200, .strength = 2, .agility = 10,
    258 			.accuracy = 100 }, .kind = PH_ENTITY_MONSTER },
    259 	};
    260 	const PhWorldCatalog content = {
    261 		.entities = entity_defs, .entity_count = (int)LEN(entity_defs),
    262 	};
    263 	PhWorld world;
    264 	PhBattle battle;
    265 	PhArea area = { 0 };
    266 	int order[PH_BATTLE_ORDER_COUNT];
    267 	int player;
    268 	int enemy;
    269 	int cast;
    270 	int expected_next;
    271 
    272 	if (ph_world_init(&world, area, 1, content) < 0 ||
    273 			(player = ph_world_spawn_entity(&world, 1, (PhVec2){ 0 }, 0, 1)) < 0 ||
    274 			(enemy = ph_world_spawn_entity(&world, 2, (PhVec2){ 16, 0 }, 0, 1)) < 0)
    275 		return 1;
    276 	ph_world_set_player(&world, player);
    277 	ph_battle_begin(&battle, &world, enemy, ph_player_spellbook);
    278 	if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) !=
    279 			PH_BATTLE_READY) return 1;
    280 	for (cast = 0; cast < 2; ++cast) {
    281 		expected_next = cast == 0 ? player : enemy;
    282 		battle.choice.command = PH_BATTLE_MAGICK;
    283 		ph_battle_select(&battle, &world);
    284 		ph_battle_cast(&battle, &world);
    285 		ph_battle_order(&battle, &world, order);
    286 		if (battle.phase != PH_BATTLE_TARGET || order[0] != player ||
    287 				order[1] != expected_next) return 1;
    288 		ph_battle_confirm_target(&battle, &world);
    289 		if (battle.turn.action_delay != 50) return 1;
    290 		if (ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS, 0) !=
    291 				PH_BATTLE_CONTINUE || battle.turn.current_actor != expected_next ||
    292 				battle.phase != (cast == 0 ? PH_BATTLE_COMMAND :
    293 				PH_BATTLE_ENEMY_ACTION)) return 1;
    294 		if (cast == 0) {
    295 			battle.choice.command = PH_BATTLE_FIGHT;
    296 			ph_battle_select(&battle, &world);
    297 			ph_battle_confirm_target(&battle, &world);
    298 			if (ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS, 0) !=
    299 					PH_BATTLE_CONTINUE || battle.phase != PH_BATTLE_ENEMY_ACTION ||
    300 					battle.turn.current_actor != enemy) return 1;
    301 			if (ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS, 0) !=
    302 					PH_BATTLE_CONTINUE || battle.phase != PH_BATTLE_COMMAND ||
    303 					battle.turn.current_actor != player) return 1;
    304 		}
    305 	}
    306 	ph_world_destroy(&world);
    307 	return 0;
    308 }
    309 
    310 static int
    311 ph_logic_test(int open, int blocked)
    312 {
    313 	enum { MAP_W = 4, MAP_H = 3, ROW = 1, LEFT_COL = 1, RIGHT_COL = 2 };
    314 	unsigned char tiles[MAP_W * MAP_H];
    315 	const PhEntityDef entity_defs[] = {
    316 		{
    317 			.id = 1, .name = "Tester",
    318 			.stats = { .max_hp = 5, .max_mp = 5, .strength = 4,
    319 				.magic = 3, .accuracy = 100, .agility = 1 },
    320 			.move_speed = 1, .kind = PH_ENTITY_PLAYER,
    321 		},
    322 		{
    323 			.id = 4, .name = "Target",
    324 			.stats = { .max_hp = 8, .strength = 2, .defense = 1,
    325 				.accuracy = 100 },
    326 			.reward = { 7, 2, 1 },
    327 			.blocks_movement = 1, .kind = PH_ENTITY_MONSTER,
    328 		},
    329 	};
    330 	const PhItemDef item_defs[] = {
    331 		{ .id = 1, .bonuses = { .max_hp = 2, .strength = 2 },
    332 			.equip_slot = PH_EQUIP_WEAPON },
    333 		{ .id = 2, .use = PH_ITEM_USE_HEAL, .use_power = 3 },
    334 		{ .id = 3, .equip_slot = PH_EQUIP_WEAPON },
    335 	};
    336 	const PhWorldCatalog content = {
    337 		.entities = entity_defs,
    338 		.items = item_defs,
    339 		.entity_count = (int)LEN(entity_defs),
    340 		.item_count = (int)LEN(item_defs),
    341 	};
    342 	PhArea area = { .name = "Smoke", .width = MAP_W, .height = MAP_H,
    343 		.tiles = tiles, .tile_defs = ph_tile_defs,
    344 		.tile_def_count = (int)LEN(ph_tile_defs) };
    345 	PhVec2 player_start = { PH_TILE_CENTER(LEFT_COL), PH_TILE_CENTER(ROW) };
    346 	PhVec2 enemy_start = { PH_TILE_CENTER(MAP_W + 1), PH_TILE_CENTER(MAP_H + 1) };
    347 	float crossing_seconds = (float)PH_WORLD_STEP_MILLISECONDS / 1000.0f;
    348 	PhWorld world;
    349 	PhBattle battle;
    350 	const PhEntity *player;
    351 	PhStats stats;
    352 	int order[PH_BATTLE_ORDER_COUNT];
    353 	int player_index;
    354 	int enemy_index;
    355 	int ally_index;
    356 	unsigned int events;
    357 
    358 	memset(tiles, blocked, sizeof(tiles));
    359 	tiles[ROW * area.width + LEFT_COL] = (unsigned char)open;
    360 	tiles[ROW * area.width + RIGHT_COL] = (unsigned char)open;
    361 	if (ph_world_init(&world, area, 1, content) < 0 ||
    362 			(player_index = ph_world_spawn_entity(&world, 1, player_start, 0, 1)) < 0 ||
    363 			(enemy_index = ph_world_spawn_entity(&world, 4, enemy_start, 0, 1)) < 0 ||
    364 			ph_world_drop_item(&world, 1, player_start, 1, 1) < 0 ||
    365 			ph_world_drop_item(&world, 2, player_start, 2, 1) < 0 ||
    366 			ph_world_drop_item(&world, 1,
    367 				(PhVec2){ PH_TILE_CENTER(RIGHT_COL), PH_TILE_CENTER(ROW) },
    368 				1, 1) < 0) return 1;
    369 	ph_world_set_player(&world, player_index);
    370 	if (ph_world_equip_item(&world, player_index, 3) < 0) return 1;
    371 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    372 	if (!world.ground_items[2].active) return 1;
    373 	if (ph_world_equip_inventory_item(&world, player_index, 1) < 0) return 1;
    374 	world.entities[player_index].vitals.hp = 1;
    375 	if (ph_world_use_inventory_item(&world, player_index, 2) < 0 ||
    376 			ph_world_drop_inventory_item(&world, player_index, 2) < 0) return 1;
    377 	events = ph_world_event_mask(&world);
    378 	if (!(events & (1u << PH_WORLD_EVENT_ITEM_PICKUP)) ||
    379 			!(events & (1u << PH_WORLD_EVENT_ITEM_DROP))) return 1;
    380 
    381 	ph_world_tick(&world, (PhInput){ .move_x = 1 }, crossing_seconds);
    382 	ph_world_tick(&world, (PhInput){ .move_x = 1, .move_y = 1 }, crossing_seconds);
    383 	player = ph_world_player(&world);
    384 	stats = ph_world_entity_stats(&world, player);
    385 	if (!player || (int)(player->pos.x / PH_TILE_SIZE) != RIGHT_COL ||
    386 			(int)(player->pos.y / PH_TILE_SIZE) != ROW ||
    387 			player->motion.facing_x != 0 || player->motion.facing_y != 1 ||
    388 			ph_world_item_amount(&world, 1) != 0 ||
    389 			ph_world_item_amount(&world, 2) != 0 ||
    390 			ph_world_item_amount(&world, 3) != 1 || player->vitals.hp != 4 ||
    391 			stats.max_hp != 7 || stats.strength != 6 ||
    392 			player->equipment[PH_EQUIP_WEAPON] != 1 ||
    393 			!world.ground_items[0].active || world.ground_items[0].item_id != 2)
    394 		return 1;
    395 	world.entities[enemy_index].pos = (PhVec2){
    396 		player->pos.x - PH_TILE_SIZE, player->pos.y };
    397 	ph_world_tick(&world, (PhInput){ .move_x = -1 }, crossing_seconds);
    398 	ph_battle_begin(&battle, &world, enemy_index, ph_player_spellbook);
    399 	if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) !=
    400 			PH_BATTLE_READY) return 1;
    401 	battle.choice.command = PH_BATTLE_FIGHT;
    402 	ph_battle_order(&battle, &world, order);
    403 	if (order[0] != player_index || order[1] != enemy_index) return 1;
    404 	battle.choice.command = PH_BATTLE_MAGICK;
    405 	ph_battle_order(&battle, &world, order);
    406 	if (order[0] != player_index || order[1] != enemy_index) return 1;
    407 	ph_battle_select(&battle, &world);
    408 	ph_battle_order(&battle, &world, order);
    409 	if (battle.phase != PH_BATTLE_MAGIC || order[0] != player_index ||
    410 			order[1] != player_index || world.entities[player_index].vitals.mp != 5 ||
    411 			world.encounter_index != enemy_index ||
    412 			ph_world_apply_accuracy_penalty(&world, enemy_index, 20, 3) < 0)
    413 		return 1;
    414 	world.entities[player_index].vitals.mp = 20;
    415 	ph_battle_cast(&battle, &world);
    416 	if (battle.phase != PH_BATTLE_TARGET ||
    417 			battle.choice.target_index != enemy_index ||
    418 			ph_battle_target_step(&battle, &world, 1) != player_index ||
    419 			ph_battle_target_step(&battle, &world, 1) != enemy_index) return 1;
    420 	ph_battle_cancel(&battle);
    421 	battle.choice.spell = 1;
    422 	ph_battle_cast(&battle, &world);
    423 	ally_index = ph_world_spawn_entity(&world, 1, player->pos, 0, 1);
    424 	if (battle.phase != PH_BATTLE_TARGET ||
    425 			battle.choice.target_index != player_index ||
    426 			ally_index < 0 ||
    427 			ph_battle_target_step(&battle, &world, 1) != enemy_index ||
    428 			ph_battle_target_step(&battle, &world, 1) != ally_index ||
    429 			ph_battle_target_step(&battle, &world, 1) != player_index) return 1;
    430 	ph_battle_cancel(&battle);
    431 	world.entities[player_index].vitals.mp = 5;
    432 	ph_battle_cancel(&battle);
    433 	ph_battle_order(&battle, &world, order);
    434 	if (battle.phase != PH_BATTLE_COMMAND || order[0] != player_index ||
    435 			order[1] != enemy_index) return 1;
    436 	ph_world_advance_effects(&world, enemy_index);
    437 	ph_world_advance_effects(&world, enemy_index);
    438 	ph_world_advance_effects(&world, enemy_index);
    439 	if (world.entities[enemy_index].accuracy.value != 0 ||
    440 			world.entities[enemy_index].accuracy.turns != 0 ||
    441 			ph_world_physical_attack(&world, player_index, enemy_index) <= 0 ||
    442 			ph_world_magic_attack(&world, player_index, enemy_index, 2, 4) <= 0 ||
    443 			world.entities[enemy_index].vitals.hp != 0 ||
    444 			ph_world_finish_encounter(&world, enemy_index) < 0 ||
    445 			player->xp != 7 || player->vitals.mp != 3 ||
    446 			ph_world_item_amount(&world, 2) != 1 || world.encounter_index != -1)
    447 		return 1;
    448 	printf("smoke hp=%d equipment=%d xp=%d\n", player->vitals.hp,
    449 		player->equipment[PH_EQUIP_WEAPON], player->xp);
    450 	return 0;
    451 }
    452 
    453 static int
    454 ph_enter_portal(PhWorld *world, int destination_area_id)
    455 {
    456 	PhEntity *player = &world->entities[world->player_index];
    457 	int i;
    458 
    459 	for (i = 0; i < world->area.marker_count; ++i) {
    460 		const PhAreaMarker *marker = &world->area.markers[i];
    461 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    462 			world->area_id, marker->symbol);
    463 		const PhTileDef *tile;
    464 
    465 		if (!def || def->kind != PH_MARKER_PORTAL ||
    466 				def->destination_area_id != destination_area_id) continue;
    467 		tile = ph_area_tile_def(&world->area, marker->tile_x, marker->tile_y);
    468 		if (!tile || tile->blocks_movement) return -1;
    469 		player->pos = (PhVec2){ PH_TILE_CENTER(marker->tile_x),
    470 			PH_TILE_CENTER(marker->tile_y) };
    471 		return ph_world_update_portal(world, ph_game_world.areas);
    472 	}
    473 	return -1;
    474 }
    475 
    476 static int
    477 ph_area_shop_test(void)
    478 {
    479 	PhWorld world;
    480 	PhEntity *player;
    481 	const PhItemDef *potion;
    482 	const PhItemDef *ether;
    483 	PhSpellBook spells = ph_player_spellbook;
    484 	int potion_before;
    485 	int slime_found = 0;
    486 	int shop_id = -1;
    487 	int i;
    488 	unsigned int events;
    489 
    490 	if (ph_world_start(&world, &ph_game_world) < 0) return 1;
    491 	player = &world.entities[world.player_index];
    492 	potion = ph_world_item_def(&world, PH_ITEM_HEALING_POTION);
    493 	ether = ph_world_item_def(&world, PH_ITEM_ETHER);
    494 	if (!potion || !ether || world.area_id != PH_AREA_MEADOW || player->gold != 40 ||
    495 			world.initialized_area_count != 1)
    496 		goto fail;
    497 	player->vitals.hp = 10;
    498 	if (ph_spellbook_use(&spells, &world, world.player_index,
    499 			PH_SPELL_FLASH) >= 0 ||
    500 			ph_spellbook_use(&spells, &world, world.player_index,
    501 				PH_SPELL_HEAL) != 18 || player->vitals.hp != 28 ||
    502 			player->vitals.mp != 40) goto fail;
    503 	player->vitals.hp = ph_world_entity_stats(&world, player).max_hp;
    504 	player->vitals.mp = ph_world_entity_stats(&world, player).max_mp;
    505 	if (!ph_area_tile_blocked(&world.area, 26, 1) ||
    506 			!ph_area_tile_blocked(&world.area, 27, 3) ||
    507 			ph_area_tile_blocked(&world.area, 28, 3)) goto fail;
    508 	if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 ||
    509 			world.area_id != PH_AREA_STONEHOLLOW ||
    510 			world.initialized_area_count != 2) goto fail;
    511 	if (ph_area_tile_blocked(&world.area, 3, 2) ||
    512 			ph_area_tile_blocked(&world.area, 8, 2) ||
    513 			!ph_area_tile_blocked(&world.area, 13, 2) ||
    514 			ph_area_tile_blocked(&world.area, 4, 4) ||
    515 			ph_area_tile_blocked(&world.area, 6, 6)) goto fail;
    516 	player->pos = (PhVec2){ PH_TILE_CENTER(13), PH_TILE_CENTER(3) };
    517 	player->motion.facing_x = 0;
    518 	player->motion.facing_y = -1;
    519 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    520 	if (strcmp(world.notice.text, "The door is locked.") != 0) goto fail;
    521 	if (ph_enter_portal(&world, PH_AREA_MIRA_SHOP) != 1 ||
    522 			world.area_id != PH_AREA_MIRA_SHOP ||
    523 			world.initialized_area_count != 3) goto fail;
    524 	if (ph_area_tile_blocked(&world.area, 0, 0) ||
    525 			ph_area_tile_blocked(&world.area, 0, 1) ||
    526 			!ph_area_tile_blocked(&world.area, 0, world.area.height - 1) ||
    527 			!ph_area_tile_blocked(&world.area, world.area.width - 1,
    528 				world.area.height - 1) ||
    529 			ph_area_tile_blocked(&world.area, 10, world.area.height - 1) ||
    530 			!ph_area_tile_blocked(&world.area, -1, 0) ||
    531 			!ph_area_tile_blocked(&world.area, world.area.width, 0)) goto fail;
    532 	for (i = 0; i < world.area.marker_count; ++i) {
    533 		const PhMarkerDef *def = ph_area_catalog_marker(ph_game_world.areas,
    534 			world.area_id, world.area.markers[i].symbol);
    535 
    536 		if (def && def->kind == PH_MARKER_PORTAL &&
    537 				def->destination_area_id == PH_AREA_STONEHOLLOW) break;
    538 	}
    539 	if (i == world.area.marker_count ||
    540 			world.area.markers[i].tile_x !=
    541 				(int)(player->pos.x / (float)PH_TILE_SIZE)) goto fail;
    542 	for (i = 0; i < world.entity_count; ++i) {
    543 		const PhEntityDef *def = ph_world_entity_def(&world,
    544 			world.entities[i].type_id);
    545 		const PhInteractionDef *interaction = def ?
    546 			ph_game_interaction_def(def->interaction_id) : NULL;
    547 
    548 		if (interaction && interaction->kind == PH_INTERACTION_SHOP) {
    549 			shop_id = interaction->content_id;
    550 			player->pos = (PhVec2){ world.entities[i].pos.x,
    551 				world.entities[i].pos.y + PH_TILE_SIZE };
    552 			player->motion.facing_x = 0;
    553 			player->motion.facing_y = -1;
    554 			break;
    555 		}
    556 	}
    557 	if (i == world.entity_count) goto fail;
    558 	ph_world_event_mask(&world);
    559 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    560 	{
    561 		const PhInteractionDef *interaction =
    562 			ph_game_interaction_def(world.interaction_id);
    563 
    564 		if (!interaction || interaction->kind != PH_INTERACTION_SHOP ||
    565 				interaction->content_id != shop_id) goto fail;
    566 	}
    567 	events = ph_world_event_mask(&world);
    568 	if (!(events & (1u << PH_WORLD_EVENT_INTERACT))) goto fail;
    569 	potion_before = ph_world_item_amount(&world, potion->id);
    570 	if (ph_world_buy_item(&world, world.player_index, potion->id) < 0 ||
    571 			player->gold != 40 - potion->buy_value ||
    572 			ph_world_item_amount(&world, potion->id) != potion_before + 1 ||
    573 			ph_world_sell_item(&world, world.player_index, potion->id) < 0 ||
    574 			player->gold != 40 - potion->buy_value + potion->sell_value) goto fail;
    575 	events = ph_world_event_mask(&world);
    576 	if (!(events & (1u << PH_WORLD_EVENT_BUY)) ||
    577 			!(events & (1u << PH_WORLD_EVENT_SELL))) goto fail;
    578 	if (ph_world_buy_item(&world, world.player_index, ether->id) < 0) goto fail;
    579 	player->vitals.mp = 0;
    580 	if (ph_world_use_inventory_item(&world, world.player_index, ether->id) < 0 ||
    581 			player->vitals.mp != ether->use_power) goto fail;
    582 	if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 ||
    583 			world.area_id != PH_AREA_STONEHOLLOW ||
    584 			world.initialized_area_count != 3 ||
    585 			ph_enter_portal(&world, PH_AREA_MAGICK_SHOP) != 1 ||
    586 			world.area_id != PH_AREA_MAGICK_SHOP ||
    587 			world.initialized_area_count != 4) goto fail;
    588 	for (i = 0; i < world.entity_count; ++i) {
    589 		const PhEntityDef *def = ph_world_entity_def(&world,
    590 			world.entities[i].type_id);
    591 		const PhInteractionDef *interaction = def ?
    592 			ph_game_interaction_def(def->interaction_id) : NULL;
    593 
    594 		if (!interaction || interaction->kind != PH_INTERACTION_SHOP ||
    595 				interaction->content_id != PH_SHOP_MAGICK ||
    596 				world.entities[i].area_id != PH_AREA_MAGICK_SHOP) continue;
    597 		player->pos = (PhVec2){ world.entities[i].pos.x,
    598 			world.entities[i].pos.y + PH_TILE_SIZE };
    599 		player->motion.facing_x = 0;
    600 		player->motion.facing_y = -1;
    601 		break;
    602 	}
    603 	if (i == world.entity_count) goto fail;
    604 	ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
    605 	{
    606 		const PhInteractionDef *interaction =
    607 			ph_game_interaction_def(world.interaction_id);
    608 
    609 		if (!interaction || interaction->content_id != PH_SHOP_MAGICK) goto fail;
    610 	}
    611 	if (!ph_spellbook_has(&spells, PH_SPELL_FLASH) ||
    612 			ph_spellbook_has(&spells, PH_SPELL_EMBER) ||
    613 			ph_spellbook_buy(&spells, &world, world.player_index,
    614 				PH_SPELL_EMBER) < 0 ||
    615 			!ph_spellbook_has(&spells, PH_SPELL_EMBER) || player->gold != 0 ||
    616 			ph_spellbook_buy(&spells, &world, world.player_index,
    617 				PH_SPELL_EMBER) == 0 ||
    618 			ph_spellbook_buy(&spells, &world, world.player_index,
    619 				PH_SPELL_METEOR) == 0) goto fail;
    620 	if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 ||
    621 			world.area_id != PH_AREA_STONEHOLLOW ||
    622 			world.initialized_area_count != 4 ||
    623 			ph_enter_portal(&world, PH_AREA_MEADOW) != 1 ||
    624 			world.area_id != PH_AREA_MEADOW || world.initialized_area_count != 4)
    625 		goto fail;
    626 	for (i = 0; i < world.entity_count; ++i) {
    627 		const PhEntityDef *def = ph_world_entity_def(&world,
    628 			world.entities[i].type_id);
    629 
    630 		if (def && def->kind == PH_ENTITY_MONSTER &&
    631 				world.entities[i].area_id == PH_AREA_MEADOW &&
    632 				world.entities[i].active) slime_found = 1;
    633 	}
    634 	ph_world_destroy(&world);
    635 	return slime_found ? 0 : 1;
    636 
    637 fail:
    638 	ph_world_destroy(&world);
    639 	return 1;
    640 }
    641 
    642 static int
    643 ph_save_test(void)
    644 {
    645 	const char *path = "obj/test-save.phsave";
    646 	PhWorld world;
    647 	PhWorld loaded;
    648 	PhWorld checkpoint;
    649 	PhBattle checkpoint_battle;
    650 	PhSpellBook spells = ph_player_spellbook;
    651 	PhSpellBook loaded_spells = ph_player_spellbook;
    652 	PhEntity *player;
    653 	FILE *corrupt;
    654 	int enemy_hp;
    655 	int result = 1;
    656 
    657 	remove(path);
    658 	remove("obj/test-save.phsave.tmp");
    659 	if (ph_save_load(path, &loaded, &ph_game_world, &loaded_spells) != 0 ||
    660 			ph_world_start(&world, &ph_game_world) < 0) return 1;
    661 	player = &world.entities[world.player_index];
    662 	player->vitals.hp = 23;
    663 	player->xp = 91;
    664 	player->gold = 137;
    665 	world.event_dropped = 3;
    666 	world.inventory[3] = 4;
    667 	if (ph_spellbook_learn(&spells, PH_SPELL_EMBER) < 0) goto done_world;
    668 	if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 ||
    669 			world.entity_count != 1) goto done_world;
    670 	if (ph_save_write(path, &world, &spells) < 0) goto done_world;
    671 	if (ph_save_load(path, &loaded, &ph_game_world, &loaded_spells) != 1) {
    672 		fprintf(stderr, "save round trip failed to load\n");
    673 		goto done_world;
    674 	}
    675 	player = &loaded.entities[loaded.player_index];
    676 	if (loaded.area_id != PH_AREA_STONEHOLLOW || loaded.entity_count != 1 ||
    677 			loaded.initialized_area_count != 2 || loaded.event_dropped != 0 || player->vitals.hp != 23 ||
    678 			player->xp != 91 || player->gold != 137 ||
    679 			ph_world_item_amount(&loaded, PH_ITEM_HEALING_POTION) != 4 ||
    680 			!ph_spellbook_has(&loaded_spells, PH_SPELL_FLASH) ||
    681 			!ph_spellbook_has(&loaded_spells, PH_SPELL_HEAL) ||
    682 			!ph_spellbook_has(&loaded_spells, PH_SPELL_EMBER)) {
    683 		fprintf(stderr, "save round trip restored incorrect global state\n");
    684 		goto done_loaded;
    685 	}
    686 	if (ph_enter_portal(&loaded, PH_AREA_MEADOW) != 1 ||
    687 			loaded.entity_count != 2 || loaded.ground_item_count != 2) {
    688 		fprintf(stderr, "save round trip restored incorrect area state: area=%d entities=%d items=%d\n",
    689 			loaded.area_id, loaded.entity_count, loaded.ground_item_count);
    690 		goto done_loaded;
    691 	}
    692 	if (ph_save_write(path, &loaded, &loaded_spells) < 0) goto done_loaded;
    693 	player = &loaded.entities[1];
    694 	enemy_hp = player->vitals.hp;
    695 	ph_battle_begin(&checkpoint_battle, &loaded, 1, loaded_spells);
    696 	--loaded.entities[1].vitals.hp;
    697 	if (ph_save_write(path, &loaded, &loaded_spells) != -1 ||
    698 			ph_save_load(path, &checkpoint, &ph_game_world, &loaded_spells) != 1) goto done_loaded;
    699 	if (checkpoint.entity_count != 2 ||
    700 			checkpoint.entities[1].vitals.hp != enemy_hp) {
    701 		ph_world_destroy(&checkpoint);
    702 		fprintf(stderr, "mid-combat save overwrote stable checkpoint\n");
    703 		goto done_loaded;
    704 	}
    705 	ph_world_destroy(&checkpoint);
    706 	corrupt = fopen(path, "wb");
    707 	if (!corrupt) goto done_loaded;
    708 	if (fwrite("bad", 1, 3, corrupt) != 3) {
    709 		fclose(corrupt);
    710 		goto done_loaded;
    711 	}
    712 	if (fclose(corrupt) != 0) goto done_loaded;
    713 	if (ph_save_load(path, &(PhWorld){ 0 }, &ph_game_world, &loaded_spells) != -1) goto done_loaded;
    714 	result = 0;
    715 
    716 done_loaded:
    717 	ph_world_destroy(&loaded);
    718 done_world:
    719 	ph_world_destroy(&world);
    720 	remove(path);
    721 	remove("obj/test-save.phsave.tmp");
    722 	return result;
    723 }
    724 
    725 int
    726 main(void)
    727 {
    728 	int open = -1;
    729 	int blocked = -1;
    730 	size_t tile;
    731 
    732 	for (tile = 0; tile < LEN(ph_tile_defs); ++tile) {
    733 		if (ph_tile_defs[tile].blocks_movement) blocked = (int)tile;
    734 		else open = (int)tile;
    735 	}
    736 	if (open < 0 || blocked < 0) {
    737 		fprintf(stderr, "smoke test failed: tile configuration\n");
    738 		return 1;
    739 	}
    740 	if (ph_event_overflow_test()) {
    741 		fprintf(stderr, "smoke test failed: event overflow diagnostics\n");
    742 		return 1;
    743 	}
    744 	if (ph_area_catalog_test()) {
    745 		fprintf(stderr, "smoke test failed: engine area catalog\n");
    746 		return 1;
    747 	}
    748 	if (ph_movement_test(open, blocked)) {
    749 		fprintf(stderr, "smoke test failed: world movement\n");
    750 		return 1;
    751 	}
    752 	if (ph_area_state_scaling_test()) {
    753 		fprintf(stderr, "smoke test failed: scalable area state storage\n");
    754 		return 1;
    755 	}
    756 	if (ph_flash_delay_test()) {
    757 		fprintf(stderr, "smoke test failed: repeat Flash delay\n");
    758 		return 1;
    759 	}
    760 	if (ph_logic_test(open, blocked)) {
    761 		fprintf(stderr, "smoke test failed: world or battle logic\n");
    762 		return 1;
    763 	}
    764 	if (ph_area_shop_test()) {
    765 		fprintf(stderr, "smoke test failed: area or shop logic\n");
    766 		return 1;
    767 	}
    768 	if (ph_save_test()) {
    769 		fprintf(stderr, "smoke test failed: save persistence\n");
    770 		return 1;
    771 	}
    772 	return 0;
    773 }