phantasia

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

commit 47f7871a37c91a4b50f73b680432929a776330a5
parent b3a1ae00b01e5a973afd09e8a4b19bb2ce82e9db
Author: beep <beep@wimdupont.com>
Date:   Thu, 30 Jul 2026 12:20:21 +0000

Protect saves during combat

Diffstat:
MREADME.adoc | 2++
Mphantasia.6 | 2++
Msrc/engine/battle.c | 3++-
Msrc/engine/battle.h | 2+-
Msrc/engine/save.c | 5+++--
Msrc/engine/world.c | 1+
Msrc/engine/world.h | 1+
Msrc/game/main.c | 13++++++++++---
Msrc/tests/smoke.c | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 103 insertions(+), 7 deletions(-)

diff --git a/README.adoc b/README.adoc @@ -98,6 +98,8 @@ The game saves atomically on area transitions and clean exit. The save path is configured by `PH_SAVE_PATH`; static map tiles are reloaded from game data while player progression and each visited area's mutable actors and ground items are persisted. +Starting combat creates a stable pre-combat checkpoint. Exiting during combat +does not overwrite it with a partial battle; victory saves the finalized result. == Maps diff --git a/phantasia.6 b/phantasia.6 @@ -15,6 +15,8 @@ turn-based battles. At startup, Continue restores the autosave and New Game starts fresh. The game saves player progression and mutable area state on area transitions and clean exit. +Combat creates a pre-battle checkpoint; quitting mid-battle does not save a +partial encounter, while victory saves its finalized result. .Sh KEYBINDINGS .Bl -tag -width "Arrow keys / WASD / HJKL" .It Arrow keys / WASD / HJKL diff --git a/src/engine/battle.c b/src/engine/battle.c @@ -205,13 +205,14 @@ ph_battle_spell_step(PhBattle *battle, int direction) } void -ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index, +ph_battle_begin(PhBattle *battle, PhWorld *world, int enemy_index, PhSpellBook spells) { const PhEntity *enemy = &world->entities[enemy_index]; const PhEntityDef *def = ph_world_entity_def(world, enemy->type_id); memset(battle, 0, sizeof(*battle)); + world->combat_active = 1; battle->spells = spells; battle->enemy_index = enemy_index; battle->turn.current_actor = world->player_index; diff --git a/src/engine/battle.h b/src/engine/battle.h @@ -99,7 +99,7 @@ typedef struct { PhBattleEvent event; } PhBattle; -void ph_battle_begin(PhBattle *battle, const PhWorld *world, int enemy_index, +void ph_battle_begin(PhBattle *battle, PhWorld *world, int enemy_index, PhSpellBook spells); void ph_battle_select(PhBattle *battle, PhWorld *world); int ph_battle_spell_step(PhBattle *battle, int direction); diff --git a/src/engine/save.c b/src/engine/save.c @@ -190,8 +190,9 @@ ph_save_write(const char *path, PhWorld *world, const PhSpellBook *spells) size_t length; int result = -1; - if (!path || !world || !spells || spells->learned_count < 0 || - spells->learned_count > PH_SPELL_MAX) return -1; + if (!path || !world || world->combat_active || !spells || + spells->learned_count < 0 || spells->learned_count > PH_SPELL_MAX) + return -1; length = strlen(path); temporary = malloc(length + 5); if (!temporary) return -1; diff --git a/src/engine/world.c b/src/engine/world.c @@ -945,6 +945,7 @@ ph_world_finish_encounter(PhWorld *world, int enemy_index) if (item_index >= 0) world->inventory[item_index] += def->reward.amount; enemy->active = 0; world->encounter_index = -1; + world->combat_active = 0; return 0; } diff --git a/src/engine/world.h b/src/engine/world.h @@ -248,6 +248,7 @@ typedef struct { int area_state_capacity; int initialized_area_count; int encounter_index; + int combat_active; int interaction_id; unsigned int rng_state; PhNotice notice; diff --git a/src/game/main.c b/src/game/main.c @@ -35,12 +35,15 @@ ph_update_game(PhGameContext *context, PhInput input, float dt) PhGameMode *game = context->mode; PhBattleResult result = PH_BATTLE_CONTINUE; int old_area = world->area_id; + int save_requested = 0; if (*game == PH_GAME_WORLD && context->menu == PH_MENU_NONE) { ph_world_tick(world, input, dt); if (ph_world_update_portal(world, ph_game_world.areas) < 0) fprintf(stderr, "failed to enter area\n"); if (world->encounter_index >= 0) { + if (ph_save_write(PH_SAVE_PATH, world, context->spells) < 0) + fprintf(stderr, "failed to save pre-combat checkpoint\n"); ph_battle_begin(context->battle, world, world->encounter_index, *context->spells); *game = PH_GAME_TRANSITION; @@ -51,9 +54,12 @@ ph_update_game(PhGameContext *context, PhInput input, float dt) result = ph_battle_update(context->battle, world, dt, *game == PH_GAME_TRANSITION); if (result == PH_BATTLE_READY) *game = PH_GAME_BATTLE; - else if (result == PH_BATTLE_VICTORY) *game = PH_GAME_VICTORY; + else if (result == PH_BATTLE_VICTORY) { + *game = PH_GAME_VICTORY; + save_requested = 1; + } else if (result == PH_BATTLE_DEFEAT) *game = PH_GAME_OVER; - return world->area_id != old_area; + return save_requested || world->area_id != old_area; } static int @@ -377,7 +383,8 @@ ph_run_game(void) ph_render_assets_destroy(&assets); SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); - if (ph_save_write(PH_SAVE_PATH, &world, &spells) < 0) + if ((game == PH_GAME_WORLD || game == PH_GAME_VICTORY) && + ph_save_write(PH_SAVE_PATH, &world, &spells) < 0) fprintf(stderr, "failed to save game\n"); ph_world_destroy(&world); SDL_Quit(); diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -227,6 +227,65 @@ done: } static int +ph_flash_delay_test(void) +{ + const PhEntityDef entity_defs[] = { + { .id = 1, .stats = { .max_hp = 200, .max_mp = 100, .magic = 8, + .agility = 10, .accuracy = 100 }, .kind = PH_ENTITY_PLAYER }, + { .id = 2, .stats = { .max_hp = 200, .strength = 2, .agility = 10, + .accuracy = 100 }, .kind = PH_ENTITY_MONSTER }, + }; + const PhWorldCatalog content = { + .entities = entity_defs, .entity_count = (int)LEN(entity_defs), + }; + PhWorld world; + PhBattle battle; + PhArea area = { 0 }; + int order[PH_BATTLE_ORDER_COUNT]; + int player; + int enemy; + int cast; + int expected_next; + + if (ph_world_init(&world, area, 1, content, 32.0f, 32.0f) < 0 || + (player = ph_world_spawn_entity(&world, 1, (PhVec2){ 0 }, 0, 1)) < 0 || + (enemy = ph_world_spawn_entity(&world, 2, (PhVec2){ 16, 0 }, 0, 1)) < 0) + return 1; + ph_world_set_player(&world, player); + ph_battle_begin(&battle, &world, enemy, ph_player_spellbook); + if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) != + PH_BATTLE_READY) return 1; + for (cast = 0; cast < 2; ++cast) { + expected_next = cast == 0 ? player : enemy; + battle.choice.command = PH_BATTLE_MAGICK; + ph_battle_select(&battle, &world); + ph_battle_cast(&battle, &world); + ph_battle_order(&battle, &world, order); + if (battle.phase != PH_BATTLE_TARGET || order[0] != player || + order[1] != expected_next) return 1; + ph_battle_confirm_target(&battle, &world); + if (battle.turn.action_delay != 50) return 1; + if (ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS, 0) != + PH_BATTLE_CONTINUE || battle.turn.current_actor != expected_next || + battle.phase != (cast == 0 ? PH_BATTLE_COMMAND : + PH_BATTLE_ENEMY_ACTION)) return 1; + if (cast == 0) { + battle.choice.command = PH_BATTLE_FIGHT; + ph_battle_select(&battle, &world); + ph_battle_confirm_target(&battle, &world); + if (ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS, 0) != + PH_BATTLE_CONTINUE || battle.phase != PH_BATTLE_ENEMY_ACTION || + battle.turn.current_actor != enemy) return 1; + if (ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS, 0) != + PH_BATTLE_CONTINUE || battle.phase != PH_BATTLE_COMMAND || + battle.turn.current_actor != player) return 1; + } + } + ph_world_destroy(&world); + return 0; +} + +static int ph_logic_test(int open, int blocked) { enum { MAP_W = 4, MAP_H = 3, ROW = 1, LEFT_COL = 1, RIGHT_COL = 2 }; @@ -567,10 +626,13 @@ ph_save_test(void) const char *path = "obj/test-save.phsave"; PhWorld world; PhWorld loaded; + PhWorld checkpoint; + PhBattle checkpoint_battle; PhSpellBook spells = ph_player_spellbook; PhSpellBook loaded_spells = ph_player_spellbook; PhEntity *player; FILE *corrupt; + int enemy_hp; int result = 1; remove(path); @@ -609,6 +671,21 @@ ph_save_test(void) loaded.area_id, loaded.entity_count, loaded.ground_item_count); goto done_loaded; } + if (ph_save_write(path, &loaded, &loaded_spells) < 0) goto done_loaded; + player = &loaded.entities[1]; + enemy_hp = player->vitals.hp; + ph_battle_begin(&checkpoint_battle, &loaded, 1, loaded_spells); + --loaded.entities[1].vitals.hp; + if (ph_save_write(path, &loaded, &loaded_spells) != -1 || + ph_save_load(path, &checkpoint, &ph_game_world, &loaded_spells, + 320.0f, 240.0f) != 1) goto done_loaded; + if (checkpoint.entity_count != 2 || + checkpoint.entities[1].vitals.hp != enemy_hp) { + ph_world_destroy(&checkpoint); + fprintf(stderr, "mid-combat save overwrote stable checkpoint\n"); + goto done_loaded; + } + ph_world_destroy(&checkpoint); corrupt = fopen(path, "wb"); if (!corrupt) goto done_loaded; if (fwrite("bad", 1, 3, corrupt) != 3) { @@ -656,6 +733,10 @@ main(void) fprintf(stderr, "smoke test failed: scalable area state storage\n"); return 1; } + if (ph_flash_delay_test()) { + fprintf(stderr, "smoke test failed: repeat Flash delay\n"); + return 1; + } if (ph_logic_test(open, blocked)) { fprintf(stderr, "smoke test failed: world or battle logic\n"); return 1;