commit 47f7871a37c91a4b50f73b680432929a776330a5
parent b3a1ae00b01e5a973afd09e8a4b19bb2ce82e9db
Author: beep <beep@wimdupont.com>
Date: Thu, 30 Jul 2026 12:20:21 +0000
Protect saves during combat
Diffstat:
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;