From cefe11523c9fd8e470ad552a7432efd3049497da Mon Sep 17 00:00:00 2001 From: rexim Date: Tue, 8 Oct 2024 11:09:36 +0700 Subject: [PATCH] [server] factor out stats into server::stats module --- server.c3 | 426 ++++++++++++++++++++++++++-------------------------- server.wasm | Bin 87211 -> 87226 bytes 2 files changed, 215 insertions(+), 211 deletions(-) diff --git a/server.c3 b/server.c3 index ead339c..58ed150 100644 --- a/server.c3 +++ b/server.c3 @@ -1,208 +1,24 @@ module server; +import common; import std::io; import std::math; -import std::collections::ringbuffer; import std::collections::list; import std::collections::map; -import common; - -/// Stats ////////////////////////////// extern fn int platform_now_secs(); extern fn uint platform_send_message(uint player_id, void *message); extern fn uint platform_now_msecs(); const int SERVER_FPS = 60; -const usz AVERAGE_CAPACITY = 30; -def Samples = RingBuffer(); - -enum StatKind { - COUNTER, - AVERAGE, - TIMER, -} - -struct StatCounter { - int value; -} - -struct StatAverage { - Samples samples; -} - -struct StatTimer { - uint started_at; -} - -struct Stat { - StatKind kind; - String description; - - union { - StatCounter counter; - StatAverage average; - StatTimer timer; - } -} - -enum StatEntry: usz { - UPTIME, - TICKS_COUNT, - TICK_TIMES, - MESSAGES_SENT, - MESSAGES_RECEIVED, - TICK_MESSAGES_SENT, - TICK_MESSAGES_RECEIVED, - BYTES_SENT, - BYTES_RECEIVED, - TICK_BYTE_SENT, - TICK_BYTE_RECEIVED, - PLAYERS_CURRENTLY, - PLAYERS_JOINED, - PLAYERS_LEFT, - BOGUS_AMOGUS_MESSAGES, - PLAYERS_REJECTED, - COUNT, -} - -// TODO: Why do I have to cast to usz in here? Is there cleaner way to do this? -Stat[(usz)StatEntry.COUNT] stats = { - [StatEntry.UPTIME] = { - .kind = TIMER, - .description = "Uptime" - }, - [StatEntry.TICKS_COUNT] = { - .kind = COUNTER, - .description = "Ticks count" - }, - [StatEntry.TICK_TIMES] = { - .kind = AVERAGE, - .description = "Average time to process a tick" - }, - [StatEntry.MESSAGES_SENT] = { - .kind = COUNTER, - .description = "Total messages sent" - }, - [StatEntry.MESSAGES_RECEIVED] = { - .kind = COUNTER, - .description = "Total messages received" - }, - [StatEntry.TICK_MESSAGES_SENT] = { - .kind = AVERAGE, - .description = "Average messages sent per tick" - }, - [StatEntry.TICK_MESSAGES_RECEIVED] = { - .kind = AVERAGE, - .description = "Average messages received per tick" - }, - [StatEntry.BYTES_SENT] = { - .kind = COUNTER, - .description = "Total bytes sent" - }, - [StatEntry.BYTES_RECEIVED] = { - .kind = COUNTER, - .description = "Total bytes received" - }, - [StatEntry.TICK_BYTE_SENT] = { - .kind = AVERAGE, - .description = "Average bytes sent per tick" - }, - [StatEntry.TICK_BYTE_RECEIVED] = { - .kind = AVERAGE, - .description = "Average bytes received per tick" - }, - [StatEntry.PLAYERS_CURRENTLY] = { - .kind = COUNTER, - .description = "Currently players" - }, - [StatEntry.PLAYERS_JOINED] = { - .kind = COUNTER, - .description = "Total players joined" - }, - [StatEntry.PLAYERS_LEFT] = { - .kind = COUNTER, - .description = "Total players left" - }, - [StatEntry.BOGUS_AMOGUS_MESSAGES] = { - .kind = COUNTER, - .description = "Total bogus-amogus messages" - }, - [StatEntry.PLAYERS_REJECTED] = { - .kind = COUNTER, - .description = "Total players rejected" - }, -}; -int messages_recieved_within_tick = 0; -int bytes_received_within_tick = 0; -int message_sent_within_tick = 0; -int bytes_sent_within_tick = 0; fn void send_message_and_update_stats(uint player_id, void* message) { uint sent = platform_send_message(player_id, message); if (sent > 0) { - bytes_sent_within_tick += sent; - message_sent_within_tick += 1; + stats::bytes_sent_within_tick += sent; + stats::message_sent_within_tick += 1; } } -fn float Samples.average(&self) { - float sum = 0; - for (usz i = 0; i < self.written; ++i) { - sum += self.get(i); - } - return sum/self.written; -} - -fn String get_stat(Stat *stat) { - switch (stat.kind) { - case COUNTER: return string::tformat("%d", stat.counter.value); - case AVERAGE: return string::tformat("%f", stat.average.samples.average()); - case TIMER: return display_time_interval(platform_now_secs() - stat.timer.started_at); - } -} - -fn String plural_number(int num, String singular, String plural) { - return num == 1 ? singular : plural; -} - -fn String display_time_interval(uint diffSecs) { - String[4] result; - usz result_count = 0; - - uint days = diffSecs/60/60/24; - if (days > 0) result[result_count++] = string::tformat("%d %s", days, plural_number(days, "day", "days")); - uint hours = diffSecs/60/60%24; - if (hours > 0) result[result_count++] = string::tformat("%d %s", hours, plural_number(hours, "hour", "hours")); - uint mins = diffSecs/60%60; - if (mins > 0) result[result_count++] = string::tformat("%d %s", mins, plural_number(mins, "min", "mins")); - uint secs = diffSecs%60; - if (secs > 0) result[result_count++] = string::tformat("%d %s", secs, plural_number(secs, "sec", "secs")); - return result_count == 0 ? "0 secs" : string::join_new(&result, " ", allocator::temp()); -} - -fn void stats_print_per_n_ticks(int n) { - if (stats[StatEntry.TICKS_COUNT].counter.value%n == 0) { - io::printn("Stats:"); - foreach (&stat: stats) { - io::printn(string::tformat(" %s %s", stat.description, get_stat(stat))); - } - } -} - -fn void stats_push_sample(StatEntry entry, float sample) { - assert(entry < StatEntry.COUNT); - Stat *stat = &stats[entry]; - assert(stat.kind == StatKind.AVERAGE); - stat.average.samples.push(sample); -} - -fn void stats_inc_counter(StatEntry entry, int delta) { - assert(entry < StatEntry.COUNT); - Stat *stat = &stats[entry]; - assert(stat.kind == StatKind.AVERAGE); - stat.counter.value += delta; -} - /// Items ////////////////////////////// List() collected_items; @@ -314,8 +130,8 @@ HashMap() ping_ids; fn void register_new_player(uint id) @extern("register_new_player") @wasm { assert(!players.has_key(id)); joined_ids.set(id, true); - stats_inc_counter(PLAYERS_JOINED, 1); - stats_inc_counter(PLAYERS_CURRENTLY, 1); + stats::inc_counter(PLAYERS_JOINED, 1); + stats::inc_counter(PLAYERS_CURRENTLY, 1); players.set(id, { .player = { .id = id, @@ -327,8 +143,8 @@ fn void unregister_player(uint id) @extern("unregister_player") @wasm { if (catch joined_ids.remove(id)) { left_ids.set(id, false); } - stats_inc_counter(PLAYERS_LEFT, 1); - stats_inc_counter(PLAYERS_CURRENTLY, -1); + stats::inc_counter(PLAYERS_LEFT, 1); + stats::inc_counter(PLAYERS_CURRENTLY, -1); players.remove(id); } @@ -577,10 +393,10 @@ fn bool verify_amma_moving_message(Message *message) { } fn bool process_message_on_server(uint id, Message *message) @extern("process_message_on_server") @wasm { - stats_inc_counter(MESSAGES_RECEIVED, 1); - messages_recieved_within_tick += 1; - stats_inc_counter(BYTES_RECEIVED, message.size); - bytes_received_within_tick += message.size; + stats::inc_counter(MESSAGES_RECEIVED, 1); + stats::messages_recieved_within_tick += 1; + stats::inc_counter(BYTES_RECEIVED, message.size); + stats::bytes_received_within_tick += message.size; if (verify_amma_moving_message(message)) { player_update_moving(id, (AmmaMovingMessage*)message); } else if (verify_amma_throwing_message(message)) { @@ -589,7 +405,7 @@ fn bool process_message_on_server(uint id, Message *message) @extern("process_me schedule_ping_for_player(id, (PingMessage*)message); } else { // console.log(`Received bogus-amogus message from client ${id}:`, view) - stats_inc_counter(BOGUS_AMOGUS_MESSAGES, 1); + stats::inc_counter(BOGUS_AMOGUS_MESSAGES, 1); return false; } return true; @@ -609,31 +425,31 @@ fn uint tick() @extern("tick") @wasm { process_pings(); uint tickTime = platform_now_msecs() - timestamp; - stats_inc_counter(TICKS_COUNT, 1); - stats_push_sample(TICK_TIMES, tickTime/1000.0f); - stats_inc_counter(MESSAGES_SENT, message_sent_within_tick); - stats_push_sample(TICK_MESSAGES_SENT, message_sent_within_tick); - stats_push_sample(TICK_MESSAGES_RECEIVED, messages_recieved_within_tick); - stats_inc_counter(BYTES_SENT, bytes_sent_within_tick); - stats_push_sample(TICK_BYTE_SENT, bytes_sent_within_tick); - stats_push_sample(TICK_BYTE_RECEIVED, bytes_received_within_tick); + stats::inc_counter(TICKS_COUNT, 1); + stats::push_sample(TICK_TIMES, tickTime/1000.0f); + stats::inc_counter(MESSAGES_SENT, stats::message_sent_within_tick); + stats::push_sample(TICK_MESSAGES_SENT, stats::message_sent_within_tick); + stats::push_sample(TICK_MESSAGES_RECEIVED, stats::messages_recieved_within_tick); + stats::inc_counter(BYTES_SENT, stats::bytes_sent_within_tick); + stats::push_sample(TICK_BYTE_SENT, stats::bytes_sent_within_tick); + stats::push_sample(TICK_BYTE_RECEIVED, stats::bytes_received_within_tick); clear_intermediate_ids(); - bytes_received_within_tick = 0; - messages_recieved_within_tick = 0; - message_sent_within_tick = 0; - bytes_sent_within_tick = 0; + stats::bytes_received_within_tick = 0; + stats::messages_recieved_within_tick = 0; + stats::message_sent_within_tick = 0; + stats::bytes_sent_within_tick = 0; // TODO: serve the stats over a separate websocket, so a separate html page can poll it once in a while - stats_print_per_n_ticks(SERVER_FPS); + stats::print_per_n_ticks(SERVER_FPS); common::reset_temp_mark(); return tickTime; } fn void stats_inc_players_rejected_counter() @extern("stats_inc_players_rejected_counter") @wasm { - stats_inc_counter(PLAYERS_REJECTED, 1); + stats::inc_counter(PLAYERS_REJECTED, 1); } /// Entry point ////////////////////////////// @@ -647,8 +463,196 @@ fn void entry() @init(2048) @private { common::platform_write(&buffer[0], buffer.len); return buffer.len; }; - stats[StatEntry.UPTIME].timer.started_at = platform_now_secs(); + stats::stats[StatEntry.UPTIME].timer.started_at = platform_now_secs(); common::temp_mark = allocator::temp().used; common::scene = common::allocate_default_scene(); previous_timestamp = platform_now_msecs(); } + +/// Stats ////////////////////////////// + +module server::stats; +import std::collections::ringbuffer; +import std::io; + +const usz AVERAGE_CAPACITY = 30; +def StatSamples = RingBuffer(); + +enum StatKind { + COUNTER, + AVERAGE, + TIMER, +} + +struct StatCounter { + int value; +} + +struct StatAverage { + StatSamples samples; +} + +struct StatTimer { + uint started_at; +} + +struct Stat { + StatKind kind; + String description; + + union { + StatCounter counter; + StatAverage average; + StatTimer timer; + } +} + +enum StatEntry: usz { + UPTIME, + TICKS_COUNT, + TICK_TIMES, + MESSAGES_SENT, + MESSAGES_RECEIVED, + TICK_MESSAGES_SENT, + TICK_MESSAGES_RECEIVED, + BYTES_SENT, + BYTES_RECEIVED, + TICK_BYTE_SENT, + TICK_BYTE_RECEIVED, + PLAYERS_CURRENTLY, + PLAYERS_JOINED, + PLAYERS_LEFT, + BOGUS_AMOGUS_MESSAGES, + PLAYERS_REJECTED, + COUNT, +} + +// TODO: Why do I have to cast to usz in here? Is there cleaner way to do this? +Stat[(usz)StatEntry.COUNT] stats = { + [StatEntry.UPTIME] = { + .kind = TIMER, + .description = "Uptime" + }, + [StatEntry.TICKS_COUNT] = { + .kind = COUNTER, + .description = "Ticks count" + }, + [StatEntry.TICK_TIMES] = { + .kind = AVERAGE, + .description = "Average time to process a tick" + }, + [StatEntry.MESSAGES_SENT] = { + .kind = COUNTER, + .description = "Total messages sent" + }, + [StatEntry.MESSAGES_RECEIVED] = { + .kind = COUNTER, + .description = "Total messages received" + }, + [StatEntry.TICK_MESSAGES_SENT] = { + .kind = AVERAGE, + .description = "Average messages sent per tick" + }, + [StatEntry.TICK_MESSAGES_RECEIVED] = { + .kind = AVERAGE, + .description = "Average messages received per tick" + }, + [StatEntry.BYTES_SENT] = { + .kind = COUNTER, + .description = "Total bytes sent" + }, + [StatEntry.BYTES_RECEIVED] = { + .kind = COUNTER, + .description = "Total bytes received" + }, + [StatEntry.TICK_BYTE_SENT] = { + .kind = AVERAGE, + .description = "Average bytes sent per tick" + }, + [StatEntry.TICK_BYTE_RECEIVED] = { + .kind = AVERAGE, + .description = "Average bytes received per tick" + }, + [StatEntry.PLAYERS_CURRENTLY] = { + .kind = COUNTER, + .description = "Currently players" + }, + [StatEntry.PLAYERS_JOINED] = { + .kind = COUNTER, + .description = "Total players joined" + }, + [StatEntry.PLAYERS_LEFT] = { + .kind = COUNTER, + .description = "Total players left" + }, + [StatEntry.BOGUS_AMOGUS_MESSAGES] = { + .kind = COUNTER, + .description = "Total bogus-amogus messages" + }, + [StatEntry.PLAYERS_REJECTED] = { + .kind = COUNTER, + .description = "Total players rejected" + }, +}; +int messages_recieved_within_tick = 0; +int bytes_received_within_tick = 0; +int message_sent_within_tick = 0; +int bytes_sent_within_tick = 0; + +fn float StatSamples.average(&self) { + float sum = 0; + for (usz i = 0; i < self.written; ++i) { + sum += self.get(i); + } + return sum/self.written; +} + +fn String Stat.display(&stat) { + switch (stat.kind) { + case COUNTER: return string::tformat("%d", stat.counter.value); + case AVERAGE: return string::tformat("%f", stat.average.samples.average()); + case TIMER: return display_time_interval(server::platform_now_secs() - stat.timer.started_at); + } +} + +fn String plural_number(int num, String singular, String plural) { + return num == 1 ? singular : plural; +} + +fn String display_time_interval(uint diffSecs) { + String[4] result; + usz result_count = 0; + + uint days = diffSecs/60/60/24; + if (days > 0) result[result_count++] = string::tformat("%d %s", days, plural_number(days, "day", "days")); + uint hours = diffSecs/60/60%24; + if (hours > 0) result[result_count++] = string::tformat("%d %s", hours, plural_number(hours, "hour", "hours")); + uint mins = diffSecs/60%60; + if (mins > 0) result[result_count++] = string::tformat("%d %s", mins, plural_number(mins, "min", "mins")); + uint secs = diffSecs%60; + if (secs > 0) result[result_count++] = string::tformat("%d %s", secs, plural_number(secs, "sec", "secs")); + return result_count == 0 ? "0 secs" : string::join_new(&result, " ", allocator::temp()); +} + +fn void push_sample(StatEntry entry, float sample) { + assert(entry < StatEntry.COUNT); + Stat *stat = &stats[entry]; + assert(stat.kind == StatKind.AVERAGE); + stat.average.samples.push(sample); +} + +fn void inc_counter(StatEntry entry, int delta) { + assert(entry < StatEntry.COUNT); + Stat *stat = &stats[entry]; + assert(stat.kind == StatKind.AVERAGE); + stat.counter.value += delta; +} + +fn void print_per_n_ticks(int n) { + if (stats[StatEntry.TICKS_COUNT].counter.value%n == 0) { + io::printn("Stats:"); + foreach (&stat: stats) { + io::printn(string::tformat(" %s %s", stat.description, stat.display())); + } + } +} diff --git a/server.wasm b/server.wasm index 53360b170a88cb524281ae4d14088553802a1b4f..6a4ea67186e6e667a7f1efcda04b364f469e7e7b 100755 GIT binary patch delta 4417 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 Km9?v3&3^!ge_zJ{ delta 4323 zcmb_feNbCf7Juix7hWI{gw=@WgvbxjR?>+Y>1iFjW zKb8#lo_p@OzjN+6=bn4t%O_MPPpC3DZ_p1M$H7aGxkjsmu||)$VgC0YF>~8 z6 zCLu$eBZgWU+qhh1e?8>z+#FKDKg@4*kaN5f^2y3|0eFy{T~`hdg}+|c2{`br#{qeK zeVMMer>BRL!NF_W4ExBt>mP?6(y?Jbw*T621lyv@&UEOw-jkr(m)O4EF^26^ooliEs`IC4=85e~i0iiJ;8F6s+sfeqVqD|KrEAtumpriM zF!J*nas+|n$k+VGDE}LQS|p3r*m}hkFh&N&Akv>|s6k~(CFHnvCmbLdx+U6HH@G=j z?ch0M4{6eMXyPk5k*{`h4t|VeNmg83CFNr4l`Jq8-Yxw_fC&=N%#K%w_)WU*)QLe1 zl3le-$H}}+5UORtt!i~okiY2$U?O}Vv53#_8A1^^82V^D-S~6xjAfVH(CUiN?w%p? zkzqLwNQsfU?^8_)*_5Oqzcn_nojm&uyD(^Q7jDiL7ugA}Kbl-hVSbfdO*^w`BkgvW zZl>LemcJ#xfXwME09sLTxXmcaYO5cOj)ueFxi-j8$ZQ+CKF6 zYiU>5{68?1CoL?LBN=0CqTaMm1l1nCI${XZ;vOPbGuKN~^tvoLDMD*oF$`{Xt4)O> zk7SiogsfD}Xw)-v$|k_blUx7SGFE4 z{oGzcUe5L)wL6DW8kV3V`FEi=&&V`}{@jq2!8v#X2GJd*MY}*p7^(qs$o4i2A?R)f zNE2&!4h_dN0NYd)AWDn&_$a^-`FHMIv}$f%No*6F^G+itb5*F+W9%b}?pT-%3jYMPvaS zAbo|!F>B8jeuUN?E24#E?%cm(JE!=V?bkH(Wezi#QIW*HNSQ3HFAkE+#qxKMDso!O zz7-2mHL`@IknS2VCGK@*7Cb@sX4lg#%o@VP=;j8+tO8d`x85!pdj}gzE(A-GwV>2cmT?atLl* zR0vNK|Dvw!F&bjrr(I8H*vPKKklPKlC_F|~70$${jM|>WSni5aJ!84qnYHOqtfyiv z9@w#pWw=*sW%c*ahdmr;kwpiuwiz{UCNJwx{72V*%`u$Yp4F&sI zI?O!9GilKti7`FVrx#CoMSmTy=&P(1APX{Y_#s8+tC1rAUman6)gjS3PRm~ObVq5? zK9wQktNHUW0dI_ouOlNb{(Tk2U`}? zF~jSqBj)$1EMW}PG0t?1&(JYGyN+?HBZn=E=+L|=g0Lr27thD);`u>RP`@N$lrk_X zqk|++Uz#!%yJb3_`h*R!%KfbVu^ChNewm)SnpsQ^F0Y+;kYyxJ)nHv@uaRq?8j%MI z&uVgHdV*+I*bboDJnykwS{f` zbZlrg7@lP7h1rYo)J=m|%gU+KcfGW9u5>IsL;+FBL28YmpN=B;^0d2RrGNS^`|cXD zh-y`u?l8qQ9AWKVQn>0gk9XIlRrm84s-|1}@J7HBY@W|iqG((S)W(3qhyGq5G3~|bzjDQ>bZ~krY zpwgQR#qf|am<)HLfX)Q?`d54=z~{e`VTKVLePG6KMEb1HB_l$jR+0tGL%bI_W3O^C z1KnLQzQvl^68iZa~N}YfK7*?E6 zkq#%7w{8Km^NX|)XU4C-P*X?1?+Nbb?FeqwY zALsY^gT9c#*x2FsceFc0TLWn9<_`a6qcTzhd61-hQUf_?b$l)4gGni?1rwN+%XMhw zrdlxaDT|fQ>L6Wtv=;29)c^e$PE%g5g(4LAf&y5SlsZJsP^#*{3Yp6KISR*Wc3U hnfkJKmX$9We&SY83;g^A%CR+YhObzl_!{8${{X5ANCyA_