phantasia

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

smoke.c (27821B)


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