Add unit tests for stealth and sleight of hand

This commit is contained in:
kinther 2025-12-25 08:54:28 -08:00
parent 448cb1f7a9
commit fbbc63b02b
4 changed files with 219 additions and 2 deletions

View file

@ -106,4 +106,4 @@ $(SIMS_BIN): $(SIMS_OBJS) $(SIM_LINK_OBJS) | $(BINDIR)
$(CC) $(CFLAGS) -o $@ $^ $(LFLAGS) $(LIBS) -lm
$(SIMS_DIR)/sim_5e.o: $(SIMS_SRC)
$(CC) $(CFLAGS) -I. -c -o $@ $<
$(CC) $(CFLAGS) -I. -c -o $@ $<

View file

@ -11,6 +11,9 @@
#include "utils.h"
#include "handler.h"
#include "constants.h"
#include "spells.h"
extern struct player_special_data dummy_mob;
/* ---------- local RNG for the sim (do NOT use MUD's rand_number here) ---------- */
static inline int randi_closed(int lo, int hi) {
@ -75,6 +78,30 @@ static double hit_rate(int attack_mod, int target_ac, int trials) {
return (double)hits / (double)trials;
}
/* stealth vs scan (contest): return stealth win rate */
static double stealth_vs_scan_rate(struct char_data *sneaky, struct char_data *scanner, int trials) {
int wins = 0;
for (int i=0;i<trials;++i) {
int stealth_total = roll_skill_check(sneaky, SKILL_STEALTH, 0, NULL);
int scan_total = roll_skill_check(scanner, SKILL_PERCEPTION, 0, NULL);
if (stealth_total > scan_total)
wins++;
}
return (double)wins / (double)trials;
}
/* steal check vs DC (no scan vs scan): return success rate */
static double steal_vs_dc_rate(struct char_data *thief, int dc, int trials) {
int wins = 0;
for (int i=0;i<trials;++i) {
int total = roll_skill_check(thief, SKILL_SLEIGHT_OF_HAND, 0, NULL);
if (total >= dc)
wins++;
}
return (double)wins / (double)trials;
}
/* Mirror compute_steal_dc logic for sims (uses real modifiers and flags). */
/* simple DPR per swing: roll dice + STR mod; 0 on miss (local RNG) */
static int swing_damage(int ndice, int sdice, int str_mod) {
return dice_local(ndice, sdice) + str_mod;
@ -133,6 +160,31 @@ static void build_heavy(struct char_data *ch) {
equip_at(ch, WEAR_WRIST_R, make_armor(1,1,0,0,0,0));
}
static void build_stealthy(struct char_data *ch, int dex, int skill_pct, int prof_bonus) {
init_test_char(ch);
set_ability_scores(ch, 10, dex, 10, 10, 10, 10);
ch->player.level = 1;
ch->points.prof_mod = prof_bonus - get_level_proficiency_bonus(ch);
SET_SKILL(ch, SKILL_STEALTH, skill_pct);
}
static void build_scanner(struct char_data *ch, int wis, int skill_pct, int prof_bonus) {
init_test_char(ch);
set_ability_scores(ch, 10, 10, 10, 10, wis, 10);
ch->player.level = 1;
ch->points.prof_mod = prof_bonus - get_level_proficiency_bonus(ch);
SET_SKILL(ch, SKILL_PERCEPTION, skill_pct);
}
static void build_thief(struct char_data *ch, int dex, int skill_pct, int prof_bonus) {
init_test_char(ch);
set_ability_scores(ch, 10, dex, 10, 10, 10, 10);
ch->player.level = 1;
ch->points.prof_mod = prof_bonus - get_level_proficiency_bonus(ch);
SET_SKILL(ch, SKILL_SLEIGHT_OF_HAND, skill_pct);
}
/* attacker profiles: compute attack_mod = STRmod + prof(skill%) + weapon_magic */
static int compute_attack_mod(int str_score, int skill_pct, int weapon_magic) {
int mod = GET_ABILITY_MOD(str_score);
@ -146,6 +198,7 @@ static int compute_attack_mod(int str_score, int skill_pct, int weapon_magic) {
int main(void) {
/* seed local RNG (do not rely on MUD RNG here) */
srand(42);
circle_srandom(42);
/* 1) Hit-rate grid: atk_mod 0..10 vs AC 12..20 */
printf("Hit-rate grid (trials=50000):\n ");
@ -210,5 +263,55 @@ int main(void) {
printf("\n");
}
/* 4) Stealth vs Scan grid (contested d20 + ability + proficiency) */
const int profs[] = { 0, 1, 2, 3, 4, 5, 6 };
printf("Stealth vs Scan grid (trials=30000, Dex/Wis 14):\n");
printf("Columns = Scan proficiency, Rows = Stealth proficiency\n ");
for (size_t i=0;i<sizeof(profs)/sizeof(profs[0]);++i) printf("Scan%+2d ", profs[i]);
printf("\n");
for (size_t si=0; si<sizeof(profs)/sizeof(profs[0]); ++si) {
printf("Stealth%+2d ", profs[si]);
for (size_t vi=0; vi<sizeof(profs)/sizeof(profs[0]); ++vi) {
struct char_data sneaky, scanner;
build_stealthy(&sneaky, 14, 50, profs[si]);
build_scanner(&scanner, 14, 50, profs[vi]);
double p = stealth_vs_scan_rate(&sneaky, &scanner, 30000);
printf(" %5.1f", p*100.0);
}
printf("\n");
}
printf("\n");
/* 5) Steal vs fixed DC grid */
const int row_label_width = 22;
const int col_width = 6;
const int dc_list[] = { 5, 10, 14, 16, 18, 20 };
const size_t uniq_count = sizeof(dc_list) / sizeof(dc_list[0]);
printf("Steal vs DC grid (trials=30000, Dex 14):\n");
printf("Columns = DC, Rows = Sleight of Hand proficiency\n");
printf("%-*s", row_label_width, "");
for (size_t i=0;i<uniq_count;++i) {
char col_label[8];
snprintf(col_label, sizeof(col_label), "DC%d", dc_list[i]);
printf(" %*s", col_width, col_label);
}
printf("\n");
for (size_t ti=0; ti<sizeof(profs)/sizeof(profs[0]); ++ti) {
char row_label[32];
snprintf(row_label, sizeof(row_label), "Sleight of Hand%+d", profs[ti]);
printf("%-*s", row_label_width, row_label);
for (size_t di=0; di<uniq_count; ++di) {
struct char_data thief;
build_thief(&thief, 14, 50, profs[ti]);
double p = steal_vs_dc_rate(&thief, dc_list[di], 30000);
printf(" %*.*f", col_width, 1, p*100.0);
}
printf("\n");
}
printf("\n");
return 0;
}

View file

@ -7,7 +7,7 @@
/* --- Globals expected by utils.c --- */
FILE *logfile = NULL; /* used by mudlog/basic_mud_vlog */
struct descriptor_data *descriptor_list = NULL;
struct char_data dummy_mob; /* used as a safe send_to_char target */
struct player_special_data dummy_mob; /* dummy specials area for mobs */
/* --- Minimal world so in_room lookups are safe in tests/sims --- */
struct room_data stub_room; /* zeroed room */
@ -19,6 +19,11 @@ struct weather_data weather_info;
const char *pc_class_types[] = { "class", NULL };
/* --- Functions utils.c references that we don't need in tests --- */
bool has_save_proficiency(int class_num, int ability) {
(void)class_num; (void)ability;
return FALSE;
}
void send_to_char(struct char_data *ch, const char *messg, ...) {
/* no-op for tests */
(void)ch; (void)messg;

View file

@ -7,6 +7,9 @@
#include "structs.h"
#include "utils.h"
#include "constants.h"
#include "spells.h"
extern struct player_special_data dummy_mob;
/* ---------- Tiny test framework ---------- */
static int tests_run = 0, tests_failed = 0;
@ -27,6 +30,17 @@ static int tests_run = 0, tests_failed = 0;
/* ---------- Helpers for test setup ---------- */
static void init_test_char(struct char_data *ch) {
memset(ch, 0, sizeof(*ch));
ch->player_specials = calloc(1, sizeof(struct player_special_data));
ch->in_room = 0;
}
static void set_prof_bonus(struct char_data *ch, int prof_bonus) {
ch->player.level = 1;
ch->points.prof_mod = prof_bonus - get_level_proficiency_bonus(ch);
}
/* Make a simple armor object with given per-piece fields. */
static struct obj_data *make_armor(int piece_ac, int bulk, int magic, int stealth_disadv, int durability, int str_req) {
struct obj_data *o = calloc(1, sizeof(*o));
@ -57,6 +71,27 @@ static void set_ability_scores(struct char_data *ch, int str, int dex, int con,
ch->aff_abils = ch->real_abils; /* common pattern */
}
static double simulate_skill_vs_dc(struct char_data *ch, int skillnum, int dc, int trials) {
int success = 0;
for (int i = 0; i < trials; ++i) {
int total = roll_skill_check(ch, skillnum, 0, NULL);
if (total >= dc)
success++;
}
return (double)success / (double)trials;
}
static double simulate_stealth_vs_scan(struct char_data *sneaky, struct char_data *scanner, int trials) {
int success = 0;
for (int i = 0; i < trials; ++i) {
int stealth_total = roll_skill_check(sneaky, SKILL_STEALTH, 0, NULL);
int scan_total = roll_skill_check(scanner, SKILL_PERCEPTION, 0, NULL);
if (stealth_total > scan_total)
success++;
}
return (double)success / (double)trials;
}
/* For hit probability sanity tests: simulate pure d20 vs ascending AC with nat 1/20. */
static double simulate_hit_rate(int attack_mod, int target_ac, int trials) {
int hits = 0;
@ -210,11 +245,85 @@ static void test_hit_probability_sanity(void) {
T_IN_RANGE(p4, 0.05, 0.15, "Hit rate low (atk+0 vs AC20)");
}
static void test_stealth_vs_scan_proficiency(void) {
struct char_data sneaky, scanner;
init_test_char(&sneaky);
init_test_char(&scanner);
set_ability_scores(&sneaky, 10, 14, 10, 10, 10, 10); /* DEX 14 */
set_ability_scores(&scanner, 10, 10, 10, 10, 14, 10); /* WIS 14 */
SET_SKILL(&sneaky, SKILL_STEALTH, 50);
SET_SKILL(&scanner, SKILL_PERCEPTION, 50);
set_prof_bonus(&scanner, 0); /* fixed scan proficiency */
const int trials = 20000;
set_prof_bonus(&sneaky, 0);
circle_srandom(12345);
double p1 = simulate_stealth_vs_scan(&sneaky, &scanner, trials);
set_prof_bonus(&sneaky, 3);
circle_srandom(12345);
double p2 = simulate_stealth_vs_scan(&sneaky, &scanner, trials);
set_prof_bonus(&sneaky, 6);
circle_srandom(12345);
double p3 = simulate_stealth_vs_scan(&sneaky, &scanner, trials);
T_ASSERT(p1 < p2 && p2 < p3,
"Stealth vs scan should improve with proficiency (%.3f < %.3f < %.3f)",
p1, p2, p3);
T_ASSERT((p3 - p1) > 0.08,
"Stealth vs scan gap should be meaningful (%.3f -> %.3f)",
p1, p3);
}
static void test_steal_vs_scan_proficiency(void) {
struct char_data thief;
init_test_char(&thief);
set_ability_scores(&thief, 10, 14, 10, 10, 10, 10); /* DEX 14 */
SET_SKILL(&thief, SKILL_SLEIGHT_OF_HAND, 50);
const int trials = 20000;
const int dc_no_scan = 10; /* compute_steal_dc base with neutral awake victim */
const int dc_scan = 15; /* base + scan bonus */
const int profs[] = { 0, 3, 6 };
double no_scan[3], scan[3];
for (size_t i = 0; i < 3; ++i) {
set_prof_bonus(&thief, profs[i]);
circle_srandom(24680);
no_scan[i] = simulate_skill_vs_dc(&thief, SKILL_SLEIGHT_OF_HAND, dc_no_scan, trials);
circle_srandom(24680);
scan[i] = simulate_skill_vs_dc(&thief, SKILL_SLEIGHT_OF_HAND, dc_scan, trials);
}
T_ASSERT(no_scan[0] < no_scan[1] && no_scan[1] < no_scan[2],
"Steal vs no-scan improves with proficiency (%.3f < %.3f < %.3f)",
no_scan[0], no_scan[1], no_scan[2]);
T_ASSERT(scan[0] < scan[1] && scan[1] < scan[2],
"Steal vs scan improves with proficiency (%.3f < %.3f < %.3f)",
scan[0], scan[1], scan[2]);
T_ASSERT(scan[0] < no_scan[0] && scan[1] < no_scan[1] && scan[2] < no_scan[2],
"Scan DC should reduce steal success (no-scan %.3f/%.3f/%.3f vs scan %.3f/%.3f/%.3f)",
no_scan[0], no_scan[1], no_scan[2], scan[0], scan[1], scan[2]);
T_IN_RANGE(no_scan[0], 0.70, 0.80, "Steal no-scan +0");
T_IN_RANGE(no_scan[2], 0.93, 0.98, "Steal no-scan +6");
T_IN_RANGE(scan[0], 0.45, 0.55, "Steal scan +0");
T_IN_RANGE(scan[2], 0.65, 0.75, "Steal scan +6");
}
int main(void) {
test_GET_ABILITY_MOD();
test_GET_PROFICIENCY();
test_ac_light_medium_heavy();
test_hit_probability_sanity();
test_stealth_vs_scan_proficiency();
test_steal_vs_scan_proficiency();
printf("Tests run: %d, failures: %d\n", tests_run, tests_failed);
return tests_failed ? 1 : 0;