diff --git a/src/erlk_sup.erl b/src/erlk_sup.erl index c446f4a..8d0ec15 100644 --- a/src/erlk_sup.erl +++ b/src/erlk_sup.erl @@ -34,7 +34,7 @@ init(_) -> start => {mqtt, start_link, []} }, #{id => event_mgr, - start => {event_mgr, start_link, [[mqtt, none]]} + start => {event_mgr, start_link, [[mqtt]]} } ], {ok, {SupFlags, ChildSpecs}}. diff --git a/src/event_mgr.erl b/src/event_mgr.erl index 3ea2cdc..697470b 100644 --- a/src/event_mgr.erl +++ b/src/event_mgr.erl @@ -3,88 +3,17 @@ -behaviour(gen_server). -export([start_link/1, init/1, handle_cast/2, handle_call/3, handle_info/2]). --export([update_config/2]). --record(event_config, {database_file = none, skills_fields = [], practice_fields = [], divisions = []}). +-export([add_fields/2]). --record(division_config, {fields = [], teams = [], teams_hash = "", practice_matches = [], practice_rounds = 0, practice_seed = 0, qualification_matches = [], qualification_rounds = [], qualification_seed = 0, elimination_alliances = 0}). - --record(event_state, {owner, config = #event_config{}, fields = [], database = none, supervisor}). +-record(event_state, {owner, config = none, fields = [], database = none, supervisor}). start_link(Args) -> gen_server:start_link({local, ?MODULE}, ?MODULE, Args, []). -init([Owner, none]) -> - {ok, Supervisor} = event_sup:start_link(), - {ok, #event_state{owner = Owner, supervisor = Supervisor}}; -init([Owner, ConfigJSON]) -> +init([Owner]) -> {ok, Supervisor} = event_sup:start_link(), - {ok, parse_event_config(ConfigJSON, #event_state{owner = Owner, supervisor = Supervisor})}. - -get_matches_from_db(Database, Division, Round, Seed, Size, TeamsHash) -> - if Size == 0 -> []; - Size > 0 -> SQL = io_lib:format("SELECT number, blue_1, blue_2, red_1, red_2 FROM matches WHERE division = '~p' AND round = '~p' AND size = '~p' AND seed = '~p' AND teams = '~s';", [Division, Round, Size, Seed, TeamsHash]), - [{columns, Columns}, {rows, Rows}] = sqlite3:sql_exec(Database, SQL), - [] - end. - -parse_division([], _, Parsed, Fields, _) -> {Parsed, Fields}; -parse_division([Division | Divisions], Database, Parsed, Fields, N) -> - TeamsBinary = maps:get(<<"teams">>, Division), - TeamsList = [binary_to_list(X) || X <- TeamsBinary], - TeamsHash = schedule:hash_teams_list(TeamsList), - - PracticeRounds = maps:get(<<"practice_rounds">>, Division), - PracticeSeed = maps:get(<<"practice_seed">>, Division), - PracticeMatches = get_matches_from_db(Database, N, practice, PracticeSeed, PracticeRounds, TeamsHash), - - QualificationRounds = maps:get(<<"qualification_rounds">>, Division), - QualificationSeed = maps:get(<<"qualification_seed">>, Division), - QualificationMatches = get_matches_from_db(Database, N, qualification, QualificationSeed, PracticeRounds, TeamsHash), - - DivConfig = #division_config{ - fields = [binary_to_list(X) || X <- maps:get(<<"fields">>, Division)], - elimination_alliances = maps:get(<<"elimination_alliances">>, Division), - practice_rounds = PracticeRounds, - practice_matches = PracticeMatches, - practice_seed = PracticeSeed, - qualification_rounds = QualificationRounds, - qualification_matches = QualificationMatches, - qualification_seed = QualificationSeed, - teams = TeamsList, - teams_hash = TeamsHash - }, - parse_division(Divisions, Database, [DivConfig | Parsed], lists:append([DivConfig#division_config.fields, Fields]), N+1). - -parse_divisions(Divisions, Database) -> - parse_division(Divisions, Database, [], [], 0). - - -parse_event_config(ConfigJSON, State) -> - Config = jsone:decode(list_to_binary(ConfigJSON)), - CurrentFile = State#event_state.config#event_config.database_file, - NewFile = binary_to_list(maps:get(<<"database">>, Config)), - {ok, Database} = if CurrentFile == NewFile -> {ok, State#event_state.database}; - CurrentFile == none -> sqlite3:open(anonymous, [{file, NewFile}]); - true -> sqlite3:close(State#event_state.database), sqlite3:open(anonymous, [{file, NewFile}]) - end, - {Divisions, DivisionFields} = parse_divisions(maps:get(<<"divisions">>, Config), Database), - PracticeFields = [binary_to_list(X) || X <- maps:get(<<"testing_fields">>, Config)], - SkillsFields = [binary_to_list(X) || X <- maps:get(<<"skills_fields">>, Config)], - EliminationFields = [binary_to_list(X) || X <- maps:get(<<"elimination_fields">>, Config)], - Fields = sets:to_list(sets:from_list(lists:append([DivisionFields, SkillsFields, PracticeFields, EliminationFields]))), - - FieldStates = add_fields(Fields, State#event_state.fields), - - State#event_state{config = #event_config{ - database_file = binary_to_list(maps:get(<<"database">>, Config)), - skills_fields = SkillsFields, - practice_fields = PracticeFields, - divisions = Divisions - }, fields = FieldStates, database = Database}. - -update_config(Server, ConfigJSON) -> - gen_server:call(Server, {event_config, ConfigJSON}). + {ok, #event_state{owner = Owner, supervisor = Supervisor}}. get_field_state(_, []) -> free; get_field_state(Name, [{FieldName, FieldState} | FieldStates]) -> @@ -96,32 +25,40 @@ add_fields(Fields, FieldStates) -> [{Name, get_field_state(Name, FieldStates)} || Name <- Fields]. -update_field({Name, free}, State) -> - % TODO: Calculate which event should be running on this field - % Priority List Logic: - % 1) if testing field, assign testing event - % 2) if skills field, assign skills event - % 3) if division field - % 0) in general, assign match if: (state != done && (num % num_fields) == field_index) - % 1) Get practice match from DB - % 2) Get qualification match from DB - % 2) Get lists of elimination matches from DB, assign(and generate if necessary) first where: - FieldState = free, - {{Name, FieldState}, State}; -update_field({Name, FieldState}, State) -> {{Name, FieldState}, State}. +% TODO: Calculate which event should be running on this field +% Priority List Logic: +% 1) if testing field, assign testing event +% 2) if skills field, assign skills event +% 3) if division field +% 0) in general, assign match if: (state != done && (num % num_fields) == field_index) +% 1) get practice match from DB +% 2) get qualification match from DB +% 3) get elimination match from DB +% 4) generate/write elimination match to DB +% 4) if finals field +% 1) get finals match from db +% 2) generate/write finals match to DB +% +% If the event requires multiple fields, then we need to check if the other field is free +% <- does this add the opportunity bad behaviour? Is there a possibility for two events +% that want to be on fields A & B to try and start at the same time? If so then both events would +% take both fields(since they only know the state before starting update_field) +% <- I can deal with this by making update_field 'filter' through the list with both halfs available +% for each call +update_fields(State, [], Updated) -> + State#event_state{fields = Updated}; +update_fields(State, [{Name, free} | FieldStates], Updated) -> + % TODO: assign field logic.. + update_fields(State, FieldStates, [{Name, free} | Updated]); +update_fields(State, [FieldState | FieldStates], Updated) -> + update_fields(State, FieldStates, [FieldState | Updated]). update_events(State) -> % TODO: calculate which events should be running % TODO: stop events on fields that no longer exist % TODO: stop events on fields that are running the wrong event % TODO: start every event that should be running given the config - {NewFields, NewState} = lists:mapfoldl(fun update_field/2, State, State#event_state.fields), - NewState#event_state{fields = NewFields}. - -handle_call({event_config, ConfigJSON}, _, State) -> - NewState = parse_event_config(ConfigJSON, State), - State#event_state.owner ! {new_event_config, NewState#event_state.config}, - {reply, ok, NewState}; + update_fields(State, State#event_state.fields, []). handle_call(_, _, State) -> {noreply, update_events(State)}. diff --git a/src/schedule.erl b/src/schedule.erl index 2024830..9567de2 100644 --- a/src/schedule.erl +++ b/src/schedule.erl @@ -4,14 +4,16 @@ -export([start_link/0, init/1, handle_cast/2, handle_call/3]). --export([init_db/2, generate/2]). +-include("erlk.hrl"). + +-record(state, {database_file = none, database = none, teams = [], config = none}). start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). init([]) -> rand:seed(default), - {ok, []}. + {ok, #state{}}. duplicate(1, Original, Constructed) -> lists:append(Original, Constructed); duplicate(N, Original, Constructed) -> duplicate(N-1, Original, lists:append(Original, Constructed)). @@ -40,14 +42,7 @@ fill_padding_schedule(Matches, Teams) -> hash_teams_list(Teams) -> [ Y || <> <= crypto:hash(sha, Teams), Y <- integer_to_list(X,16)]. -generate(Process, {DatabaseFile, Division, Round, Seed, Matches, Teams}) -> gen_server:call(Process, {new_schedule, DatabaseFile, Division, Round, Seed, Matches, Teams}). -init_db(Process, DatabaseFile) -> gen_server:call(Process, {init_db, DatabaseFile}). - -init_db(File) -> - IsFile = filelib:is_regular(File), - if IsFile -> file:delete(File); - true -> true end, - {ok, Database} = sqlite3:open(anonymous, [{file, File}]), +init_db(Database) -> ok = sqlite3:create_table(Database, teams, [{name, text, [not_null]}, {inspection, blob}], [{primary_key, [name]}]), @@ -61,30 +56,30 @@ init_db(File) -> {red_2, text, [not_null]}, {blue_1, text, [not_null]}, {blue_2, text, [not_null]}], - [{primary_key, [division, round, size, teams, number, seed]}, - {check, "round IN ('practice', 'qualification', 'elimination')"}, - {foreign_key, {[blue_1], teams, [name], "ON DELETE RESTRICT"}}, - {foreign_key, {[blue_2], teams, [name], "ON DELETE RESTRICT"}}, - {foreign_key, {[red_1], teams, [name], "ON DELETE RESTRICT"}}, - {foreign_key, {[red_2], teams, [name], "ON DELETE RESTRICT"}}]), + [{primary_key, [division, round, size, teams, number, seed]}, + {check, "round IN ('practice', 'qualification', 'elimination')"}, + {foreign_key, {[blue_1], teams, [name], "ON DELETE RESTRICT"}}, + {foreign_key, {[blue_2], teams, [name], "ON DELETE RESTRICT"}}, + {foreign_key, {[red_1], teams, [name], "ON DELETE RESTRICT"}}, + {foreign_key, {[red_2], teams, [name], "ON DELETE RESTRICT"}}]), ok = sqlite3:create_table(Database, match_scores, [{division, integer, [not_null]}, - {round, text, [not_null]}, - {size, integer, [not_null]}, - {teams, text, [not_null]}, - {number, integer, [not_null]}, - {seed, integer, [not_null]}, - {instance, integer, [not_null]}, - {score, blob, [not_null]}], - [{primary_key, [division, round, size, teams, number, seed, instance]}, - {foreign_key, {[division, round, size, teams, number, seed], matches, [division, round, size, teams, number, seed], "ON DELETE CASCADE"}}]), + {round, text, [not_null]}, + {size, integer, [not_null]}, + {teams, text, [not_null]}, + {number, integer, [not_null]}, + {seed, integer, [not_null]}, + {instance, integer, [not_null]}, + {score, blob, [not_null]}], + [{primary_key, [division, round, size, teams, number, seed, instance]}, + {foreign_key, {[division, round, size, teams, number, seed], matches, [division, round, size, teams, number, seed], "ON DELETE CASCADE"}}]), ok = sqlite3:create_table(Database, match_states, [{division, integer, [not_null]}, - {round, text, [not_null]}, - {size, integer, [not_null]}, - {teams, text, [not_null]}, - {number, integer, [not_null]}, - {seed, integer, [not_null]}, - {time, integer, [not_null]}, - {state, blob, [not_null]}], + {round, text, [not_null]}, + {size, integer, [not_null]}, + {teams, text, [not_null]}, + {number, integer, [not_null]}, + {seed, integer, [not_null]}, + {time, integer, [not_null]}, + {state, blob, [not_null]}], [{primary_key, [division, round, size, teams, number, seed, time]}, {foreign_key, {[division, round, size, teams, number, seed], matches, [division, round, size, teams, number, seed], "ON DELETE CASCADE"}}]), @@ -96,25 +91,43 @@ init_db(File) -> {score, blob, [not_null]}], [{primary_key, [team, type, attempt]}, {foreign_key, {[team], teams, [name], "ON DELETE CASCADE"}}]), - sqlite3:close(Database). - -write(DatabaseFile, Division, Round, Seed, Matches, Teams) -> - MatchList = create_schedule(Seed, Matches, Teams), + ok. + +delete_teams(Database, Teams) -> + SQL = lists:append(["BEGIN; ", + lists:append([io_lib:format("DELETE FROM teams WHERE name = ~p; ", + [Team]) || Team <- Teams]), + "COMMIT;"]), + sqlite3:sql_exec_script(Database, SQL). + +add_teams(Database, Teams) -> + SQL = lists:append(["BEGIN; ", + lists:append([io_lib:format("INSERT INTO teams(name) VALUES('~s') ON CONFLICT(name) DO NOTHING; ", + [Team]) || Team <- Teams]), + "COMMIT;"]), + sqlite3:sql_exec_script(Database, SQL). + +first_error([]) -> ok; +first_error([ok | Rest]) -> first_error(Rest); +first_error([Error | _]) -> Error. + +delete_matches(Database, Division, Size, Seed, TeamsHash, Round) -> + SQL = io_lib:format("DELETE FROM matches WHERE division = ~p AND size = ~p AND seed = ~p AND teams = '~s' AND round = '~p'", [Division, Size, Seed, TeamsHash, Round]), + sqlite3:sql_exec_script(Database, SQL). + +write_matches(Database, Division, Size, Seed, TeamsHash, Round, MatchTeams) -> Schedule = [[{division, Division}, {round, atom_to_list(Round)}, - {size, Matches}, - {teams, hash_teams_list(Teams)}, + {size, Size}, + {teams, TeamsHash}, {number, N}, {seed, Seed}, {blue_1, B1}, {blue_2, B2}, {red_1, R1}, - {red_2, R2}] || {N, [B1, B2, R1, R2]} <- lists:enumerate(MatchList)], - {ok, Database} = sqlite3:open(anonymous, [{file, DatabaseFile}]), + {red_2, R2}] || {N, [B1, B2, R1, R2]} <- lists:enumerate(MatchTeams)], [ok | Resp] = sqlite3:write_many(Database, matches, Schedule), - ok = lists:last(Resp), - ok = sqlite3:close(Database). - + lists:last(Resp). create_schedule(Seed, Matches, Teams) -> TeamsRepeated = duplicate(Matches, Teams), @@ -139,9 +152,92 @@ pick_matches(Teams, Matches) -> pick_matches(Teams) -> pick_matches(Teams, []). -handle_cast(_, State) -> {noreply, State}. +handle_call({new_db, DatabaseFile}, _, State) -> + ok = if State#state.database =:= none -> ok; + true -> sqlite3:close(State#state.database) + end, + Exists = filelib:is_regular(DatabaseFile), + ok = if Exists -> file:delete(DatabaseFile); + true -> ok + end, + {ok, Database} = sqlite3:open(schedule_db, [{file, DatabaseFile}]), + ok = sqlite3:sql_exec(Database, "PRAGMA foreign_keys = ON;"), + {reply, init_db(Database), State#state{database_file = DatabaseFile, database = Database}}; +handle_call({load_db, DatabaseFile}, _, State) -> + true = filelib:is_regular(DatabaseFile), + {ok, Database} = if DatabaseFile == State#state.database_file -> + {ok, State#state.database}; + true -> + ok = if State#state.database =:= none -> ok; + true -> sqlite3:close(State#state.database) + end, + {ok, DB} = sqlite3:open(schedule_db, [{file, DatabaseFile}]), + {sqlite3:sql_exec(DB, "PRAGMA foreign_keys = ON;"), DB} + end, + {reply, ok, State#state{database_file = DatabaseFile, database = Database}}. + +handle_cast({delete_teams, Removed}, State) -> + Teams = lists:filter(fun(X) -> lists:member(X, Removed) =:= false end, State#state.teams), + ok = first_error(delete_teams(State#state.database, Removed)), + {noreply, State#state{teams = Teams}}; +handle_cast({add_teams, Teams}, State) -> + ok = first_error(add_teams(State#state.database, Teams)), + {noreply, State#state{teams = Teams}}; +handle_cast({new_schedule, Division, Size, Seed, Teams, Round}, State) -> + MatchTeams = create_schedule(Seed, Size, Teams), + ok = write_matches(State#state.database, Division, Size, Seed, hash_teams_list(Teams), Round, MatchTeams), + {noreply, State}; +handle_cast({delete_schedule, Division, Size, Seed, TeamsHash, Round}, State) -> + ok = first_error(delete_matches(State#state.database, Division, Size, Seed, TeamsHash, Round)), + {noreply, State}. + +get_matches_from_db(Database, Division, Round, Seed, Size, TeamsHash) -> + if Size == 0 -> []; + Size > 0 -> SQL = io_lib:format("SELECT number, blue_1, blue_2, red_1, red_2 FROM + matches WHERE + division = '~p' AND + round = '~p' AND + size = '~p' AND + seed = '~p' AND + teams = '~s';", + [Division, Round, Size, Seed, TeamsHash]), + [{columns, Columns}, {rows, Rows}] = sqlite3:sql_exec(Database, SQL), + [] + end. -handle_call({init_db, DatabaseFile}, _, State) -> - {reply, init_db(DatabaseFile), State}; -handle_call({new_schedule, DatabaseFile, Division, Round, Seed, Matches, Teams}, _, State) -> - {reply, write(DatabaseFile, Division, Round, Seed, Matches, Teams), State}. +parse_division(Division) -> + TeamsBinary = maps:get(<<"teams">>, Division), + TeamsList = [binary_to_list(X) || X <- TeamsBinary], + TeamsHash = schedule:hash_teams_list(TeamsList), + + PracticeRounds = maps:get(<<"practice_rounds">>, Division), + PracticeSeed = maps:get(<<"practice_seed">>, Division), + + QualificationRounds = maps:get(<<"qualification_rounds">>, Division), + QualificationSeed = maps:get(<<"qualification_seed">>, Division), + + #division_config{ + fields = [binary_to_list(X) || X <- maps:get(<<"fields">>, Division)], + elimination_alliances = maps:get(<<"elimination_alliances">>, Division), + practice_rounds = PracticeRounds, + practice_seed = PracticeSeed, + qualification_rounds = QualificationRounds, + qualification_seed = QualificationSeed, + teams = TeamsList, + teams_hash = TeamsHash + }. + +parse_event_config(ConfigJSON) -> + Config = jsone:decode(list_to_binary(ConfigJSON)), + Divisions = lists:map(fun parse_division/1, maps:get(<<"divisions">>, Config)), + TestingFields = [binary_to_list(X) || X <- maps:get(<<"testing_fields">>, Config)], + SkillsFields = [binary_to_list(X) || X <- maps:get(<<"skills_fields">>, Config)], + FinalsFields = [binary_to_list(X) || X <- maps:get(<<"finals_fields">>, Config)], + + #event_config{ + database_file = binary_to_list(maps:get(<<"database">>, Config)), + finals_fields = FinalsFields, + skills_fields = SkillsFields, + testing_fields = TestingFields, + divisions = Divisions + }.