From 7cc71fb7190d3f6a3f2392449d7858fdf8d97dd4 Mon Sep 17 00:00:00 2001 From: "Arthur K." Date: Thu, 14 May 2026 20:19:22 +0300 Subject: [PATCH] init --- README.md | 140 +++++++++++++++++++++++ config.json | 6 + events | 20 ++++ go.mod | 3 + internal/challenge/actions.go | 130 ++++++++++++++++++++++ internal/challenge/engine.go | 190 ++++++++++++++++++++++++++++++++ internal/challenge/input.go | 70 ++++++++++++ internal/challenge/main_test.go | 92 ++++++++++++++++ internal/challenge/models.go | 51 +++++++++ internal/challenge/runner.go | 35 ++++++ internal/challenge/timers.go | 21 ++++ internal/challenge/timeutil.go | 58 ++++++++++ main.go | 24 ++++ 13 files changed, 840 insertions(+) create mode 100644 README.md create mode 100644 config.json create mode 100644 events create mode 100644 go.mod create mode 100644 internal/challenge/actions.go create mode 100644 internal/challenge/engine.go create mode 100644 internal/challenge/input.go create mode 100644 internal/challenge/main_test.go create mode 100644 internal/challenge/models.go create mode 100644 internal/challenge/runner.go create mode 100644 internal/challenge/timers.go create mode 100644 internal/challenge/timeutil.go create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf07578 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# System prototype +The prototype must be able to work with a configuration file and a set of external events of a certain format. +Solution should contain golang (1.22 or newer) source file/files and unit tests (optional) + + +## Description +A player is participating in a challenge. The goal is to completely clear a dungeon. The player navigates through floors and fights monsters. We need to process the events and compile the information into a final report + + +## Rules +1. Only registered players are allowed to participate in the challenge +2. The challenge ends if: + 1. The player leaves the dungeon + 2. The player cannot continue the challenge + 3. The dungeon opening time has expired + 4. Player is dead (health drops to 0) +3. When entering the boss's floor, the player receives a notification +4. The boss floor does not contain any monsters +5. The dungeon is considered complete if: + 1. All floors are cleared of monsters + 2. The boss is defeated +6. A floor is considered complete when all monsters or the boss have been killed; ***any time spent in that floor is no longer counted*** +7. The player's health cannot exceed 100 + + +## Events +- All events occur sequentially in time. (***Time of event N+1***) >= (***Time of event N***) +- Time format ***[HH:MM:SS]***. Trailing zeros are required in input and output +- The ***ExtraParam*** parameter can be a string containing multiple words. + +#### Incoming events +| EventID | ExtraParam | Comment | +| ----------|:-------------:|-----------------------------------------------------| +| 1 | | Player [`id`] registered | +| 2 | | Player [`id`] entered the dungeon | +| 3 | | Player [`id`] killed the monster | +| 4 | | Player [`id`] went to the next floor | +| 5 | | Player [`id`] went to the previous floor | +| 6 | | Player [`id`] entered the boss's floor | +| 7 | | Player [`id`] killed the boss | +| 8 | | Player [`id`] left the dungeon | +| 9 | `reason` | Player [`id`] cannot continue due to [`reason`] | +| 10 | `health` | Player [`id`] has restored [`health`] of health | +| 11 | `damage` | Player [`id`] recieved [`damage`] of damage | + +#### Outgoing events +| EventID | ExtraParam | Comment | +| ----------|:-------------:|---------------------------------------------------| +| 31 | | Player [`id`] disqualified | +| 32 | | Player [`id`] is dead | +| 33 | | Player [`id`] makes imposible move [`eventID`] | + +#### Example + +``` +[14:00:00] 1 1 +[14:00:00] 2 1 +[14:10:00] 2 2 +[14:10:00] 3 2 +[14:11:00] 2 5 +[14:12:00] 3 3 +[14:14:00] 2 3 +[14:27:00] 2 11 60 +[14:29:00] 2 11 50 +[14:40:00] 1 2 +[14:41:00] 1 3 +[14:44:00] 1 11 50 +[14:45:00] 1 3 +[14:48:00] 1 4 +[14:48:00] 1 6 +[14:49:00] 1 11 25 +[14:49:02] 1 10 80 +[14:50:00] 1 11 65 +[14:59:00] 1 7 +[15:04:00] 1 8 +``` + +#### Output +``` +[14:00:00] Player [1] registered +[14:00:00] Player [2] registered +[14:10:00] Player [2] entered the dungeon +[14:10:00] Player [3] is disqualified +[14:11:00] Player [2] makes imposible move [5] +[14:14:00] Player [2] killed the monster +[14:27:00] Player [2] recieved [60] of damage +[14:29:00] Player [2] recieved [50] of damage +[14:29:00] Player [2] is dead +[14:40:00] Player [1] entered the dungeon +[14:41:00] Player [1] killed the monster +[14:44:00] Player [1] recieved [50] of damage +[14:45:00] Player [1] killed the monster +[14:48:00] Player [1] went to the next floor +[14:48:00] Player [1] entered the boss's floor +[14:49:00] Player [1] recieved [25] of damage +[14:49:02] Player [1] has restored [80] of health +[14:50:00] Player [1] recieved [65] of damage +[14:59:00] Player [1] killed the boss +[15:04:00] Player [1] left the dungeon +``` + +## Configuration (json) +- **Floors** - Number of floors in the dungeon +- **Monsters** - Number of monsters on each floor of the dungeon +- **OpenAt** - Dungeon opening time +- **Duration** - Time until the dungeon closes in hours + + +#### Example +``` +{ + "Floors": 2, + "Monsters": 2, + "OpenAt": "14:05:00", + "Duration": 2 +} +``` + +## States +| State | Comment | +| ----------|:-----------------------------------------------------------------:| +| SUCCESS | All floors are cleared | +| FAIL | The player died or the dungeon is not considered completed | +| DISQUAL | The player cannot continue or has not completed registration | + +## Final report +1. State `SUCCESS`/`FAIL`/`DISQUAL` +2. Player ID +3. Time spent in the dungeon (all the time until the player left the dungeon or the dungeon closed) +4. Average time to clear a floor of monsters (the boss's floor is not included in the calculation) +5. Time to kill the boss +6. Player health at the end of the trial + +#### Example +``` +Final report: +[SUCCESS] 1 [00:24:00, 00:05:00, 00:11:00] HP:35 +[FAIL] 2 [00:19:00, 00:00:00, 00:00:00] HP:0 +[DISQUAL] 3 [00:00:00, 00:00:00, 00:00:00] HP:100 +``` \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..e593096 --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "Floors": 2, + "Monsters": 2, + "OpenAt": "14:05:00", + "Duration": 2 +} \ No newline at end of file diff --git a/events b/events new file mode 100644 index 0000000..641e376 --- /dev/null +++ b/events @@ -0,0 +1,20 @@ +[14:00:00] 1 1 +[14:00:00] 2 1 +[14:10:00] 2 2 +[14:10:00] 3 2 +[14:11:00] 2 5 +[14:12:00] 3 3 +[14:14:00] 2 3 +[14:27:00] 2 11 60 +[14:29:00] 2 11 50 +[14:40:00] 1 2 +[14:41:00] 1 3 +[14:44:00] 1 11 50 +[14:45:00] 1 3 +[14:48:00] 1 4 +[14:48:00] 1 6 +[14:49:00] 1 11 25 +[14:49:02] 1 10 80 +[14:50:00] 1 11 65 +[14:59:00] 1 7 +[15:04:00] 1 8 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..88edcf8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module impulse + +go 1.22 diff --git a/internal/challenge/actions.go b/internal/challenge/actions.go new file mode 100644 index 0000000..99b8a88 --- /dev/null +++ b/internal/challenge/actions.go @@ -0,0 +1,130 @@ +package challenge + +func (e *Engine) register(event Event, p *Player) { + if p.Registered || p.InDungeon { + e.impossible(event) + return + } + p.Registered = true + e.log(event.At, "Player [%d] registered", p.ID) +} + +func (e *Engine) enterDungeon(event Event, p *Player) { + if p.InDungeon || event.At < e.openAt || event.At >= e.closeAt { + e.impossible(event) + return + } + p.InDungeon = true + p.Attempted = true + p.EnteredAt = event.At + p.EndedAt = event.At + p.Floor = 1 + e.startFloorTimer(p, event.At) + e.log(event.At, "Player [%d] entered the dungeon", p.ID) +} + +func (e *Engine) killMonster(event Event, p *Player) { + if !p.InDungeon || p.Floor < 1 || p.Floor > e.regularFloors || p.MonsterKills[p.Floor] >= e.cfg.Monsters { + e.impossible(event) + return + } + p.MonsterKills[p.Floor]++ + e.log(event.At, "Player [%d] killed the monster", p.ID) + if p.MonsterKills[p.Floor] == e.cfg.Monsters { + e.finishFloorTimer(p, event.At) + } +} + +func (e *Engine) nextFloor(event Event, p *Player) { + if !p.InDungeon || p.Floor < 1 || p.Floor >= e.cfg.Floors || !e.floorCleared(p, p.Floor) { + e.impossible(event) + return + } + e.pauseFloorTimer(p, event.At) + p.Floor++ + e.startFloorTimer(p, event.At) + e.log(event.At, "Player [%d] went to the next floor", p.ID) +} + +func (e *Engine) previousFloor(event Event, p *Player) { + if !p.InDungeon || p.Floor <= 1 { + e.impossible(event) + return + } + e.pauseFloorTimer(p, event.At) + p.Floor-- + e.startFloorTimer(p, event.At) + e.log(event.At, "Player [%d] went to the previous floor", p.ID) +} + +func (e *Engine) enterBoss(event Event, p *Player) { + if !p.InDungeon || p.BossEntered || p.Floor != e.cfg.Floors || !e.allRegularFloorsCleared(p) { + e.impossible(event) + return + } + p.BossEntered = true + p.BossEnterAt = event.At + e.log(event.At, "Player [%d] entered the boss's floor", p.ID) +} + +func (e *Engine) killBoss(event Event, p *Player) { + if !p.InDungeon || !p.BossEntered || p.BossKilled { + e.impossible(event) + return + } + p.BossKilled = true + p.BossTime = event.At - p.BossEnterAt + e.log(event.At, "Player [%d] killed the boss", p.ID) +} + +func (e *Engine) leaveDungeon(event Event, p *Player) { + if !p.InDungeon { + e.impossible(event) + return + } + e.pauseFloorTimer(p, event.At) + p.InDungeon = false + p.Left = true + p.Terminal = true + p.EndedAt = event.At + e.log(event.At, "Player [%d] left the dungeon", p.ID) +} + +func (e *Engine) cannotContinue(event Event, p *Player) { + p.Disqual = true + p.Terminal = true + p.EndedAt = event.At + e.log(event.At, "Player [%d] cannot continue due to [%s]", p.ID, event.Extra) +} + +func (e *Engine) restoreHealth(event Event, p *Player) { + amount, ok := parseNonNegativeInt(event.Extra) + if !ok || !p.InDungeon { + e.impossible(event) + return + } + p.Health = min(100, p.Health+amount) + e.log(event.At, "Player [%d] has restored [%d] of health", p.ID, amount) +} + +func (e *Engine) takeDamage(event Event, p *Player) { + damage, ok := parseNonNegativeInt(event.Extra) + if !ok || !p.InDungeon { + e.impossible(event) + return + } + p.Health = max(0, p.Health-damage) + e.log(event.At, "Player [%d] recieved [%d] of damage", p.ID, damage) + if p.Health == 0 { + e.pauseFloorTimer(p, event.At) + p.Dead = true + p.InDungeon = false + p.Terminal = true + p.EndedAt = event.At + e.log(event.At, "Player [%d] is dead", p.ID) + } +} + +func (e *Engine) impossible(event Event) { + e.log(event.At, "Player [%d] makes imposible move [%d]", event.Player, event.ID) +} diff --git a/internal/challenge/engine.go b/internal/challenge/engine.go new file mode 100644 index 0000000..c9cbfe2 --- /dev/null +++ b/internal/challenge/engine.go @@ -0,0 +1,190 @@ +package challenge + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" +) + +type Engine struct { + cfg Config + openAt time.Duration + closeAt time.Duration + regularFloors int + players map[int]*Player + order map[int]bool + logs []string +} + +func NewEngine(cfg Config) (*Engine, error) { + openAt, err := parseClock(cfg.OpenAt) + if err != nil { + return nil, err + } + if cfg.Floors < 1 { + return nil, errors.New("Floors must be positive") + } + if cfg.Monsters < 0 { + return nil, errors.New("Monsters must not be negative") + } + if cfg.Duration <= 0 { + return nil, errors.New("Duration must be positive") + } + return &Engine{ + cfg: cfg, + openAt: openAt, + closeAt: openAt + time.Duration(cfg.Duration)*time.Hour, + regularFloors: max(0, cfg.Floors-1), + players: map[int]*Player{}, + order: map[int]bool{}, + }, nil +} + +func (e *Engine) Apply(event Event) { + e.closeExpired(event.At) + + p := e.player(event.Player) + if p.Terminal { + return + } + if !p.Registered && event.ID != 1 { + p.Disqual = true + p.Terminal = true + p.EndedAt = event.At + e.log(event.At, "Player [%d] is disqualified", p.ID) + return + } + + switch event.ID { + case 1: + e.register(event, p) + case 2: + e.enterDungeon(event, p) + case 3: + e.killMonster(event, p) + case 4: + e.nextFloor(event, p) + case 5: + e.previousFloor(event, p) + case 6: + e.enterBoss(event, p) + case 7: + e.killBoss(event, p) + case 8: + e.leaveDungeon(event, p) + case 9: + e.cannotContinue(event, p) + case 10: + e.restoreHealth(event, p) + case 11: + e.takeDamage(event, p) + default: + e.impossible(event) + } +} + +func (e *Engine) Output() string { + e.closeExpired(e.closeAt) + + var b strings.Builder + for _, line := range e.logs { + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString("Final report:\n") + + ids := make([]int, 0, len(e.order)) + for id := range e.order { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + p := e.players[id] + state := e.state(p) + spent := time.Duration(0) + if p.Attempted { + spent = p.EndedAt - p.EnteredAt + if spent < 0 { + spent = 0 + } + } + b.WriteString(fmt.Sprintf("[%s] %d [%s, %s, %s] HP:%d\n", state, p.ID, formatDuration(spent), formatDuration(e.avgFloorTime(p)), formatDuration(p.BossTime), p.Health)) + } + return b.String() +} + +func (e *Engine) player(id int) *Player { + if p, ok := e.players[id]; ok { + return p + } + p := &Player{ + ID: id, + Health: 100, + MonsterKills: make([]int, e.cfg.Floors+1), + FloorDurations: make([]time.Duration, e.cfg.Floors+1), + } + e.players[id] = p + e.order[id] = true + return p +} + +func (e *Engine) closeExpired(now time.Duration) { + if now < e.closeAt { + return + } + for _, p := range e.players { + if p.InDungeon && !p.Terminal { + e.pauseFloorTimer(p, e.closeAt) + p.InDungeon = false + p.Terminal = true + p.EndedAt = e.closeAt + } + } +} + +func (e *Engine) floorCleared(p *Player, floor int) bool { + if floor > e.regularFloors { + return true + } + return p.MonsterKills[floor] >= e.cfg.Monsters +} + +func (e *Engine) allRegularFloorsCleared(p *Player) bool { + for floor := 1; floor <= e.regularFloors; floor++ { + if !e.floorCleared(p, floor) { + return false + } + } + return true +} + +func (e *Engine) avgFloorTime(p *Player) time.Duration { + var total time.Duration + cleared := 0 + for floor := 1; floor <= e.regularFloors; floor++ { + if e.floorCleared(p, floor) { + total += p.FloorDurations[floor] + cleared++ + } + } + if cleared == 0 { + return 0 + } + return total / time.Duration(cleared) +} + +func (e *Engine) state(p *Player) ResultState { + if p.Disqual { + return StateDisqual + } + if e.allRegularFloorsCleared(p) && p.BossKilled { + return StateSuccess + } + return StateFail +} + +func (e *Engine) log(at time.Duration, format string, args ...any) { + e.logs = append(e.logs, fmt.Sprintf("[%s] %s", formatClock(at), fmt.Sprintf(format, args...))) +} diff --git a/internal/challenge/input.go b/internal/challenge/input.go new file mode 100644 index 0000000..ab27d12 --- /dev/null +++ b/internal/challenge/input.go @@ -0,0 +1,70 @@ +package challenge + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +func readConfig(r io.Reader) (Config, error) { + var cfg Config + if err := json.NewDecoder(r).Decode(&cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +func readEvents(r io.Reader) ([]Event, error) { + var events []Event + scanner := bufio.NewScanner(r) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + event, err := parseEvent(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + events = append(events, event) + } + return events, scanner.Err() +} + +func parseEvent(line string) (Event, error) { + fields := strings.Fields(line) + if len(fields) < 3 { + return Event{}, errors.New("not enough fields") + } + if len(fields[0]) != len("[15:04:05]") || fields[0][0] != '[' || fields[0][len(fields[0])-1] != ']' { + return Event{}, errors.New("bad time format") + } + at, err := parseClock(fields[0][1 : len(fields[0])-1]) + if err != nil { + return Event{}, err + } + player, err := strconv.Atoi(fields[1]) + if err != nil { + return Event{}, err + } + id, err := strconv.Atoi(fields[2]) + if err != nil { + return Event{}, err + } + + return Event{At: at, Player: player, ID: id, Extra: strings.Join(fields[3:], " ")}, nil +} + +func parseNonNegativeInt(value string) (int, bool) { + if strings.TrimSpace(value) != value || value == "" || strings.Contains(value, " ") { + return 0, false + } + n, err := strconv.Atoi(value) + return n, err == nil && n >= 0 +} diff --git a/internal/challenge/main_test.go b/internal/challenge/main_test.go new file mode 100644 index 0000000..1e00a9c --- /dev/null +++ b/internal/challenge/main_test.go @@ -0,0 +1,92 @@ +package challenge + +import ( + "strings" + "testing" + "time" +) + +func TestSample(t *testing.T) { + out, err := Run("../../config.json", "../../events") + if err != nil { + t.Fatal(err) + } + + want := strings.TrimLeft(` +[14:00:00] Player [1] registered +[14:00:00] Player [2] registered +[14:10:00] Player [2] entered the dungeon +[14:10:00] Player [3] is disqualified +[14:11:00] Player [2] makes imposible move [5] +[14:14:00] Player [2] killed the monster +[14:27:00] Player [2] recieved [60] of damage +[14:29:00] Player [2] recieved [50] of damage +[14:29:00] Player [2] is dead +[14:40:00] Player [1] entered the dungeon +[14:41:00] Player [1] killed the monster +[14:44:00] Player [1] recieved [50] of damage +[14:45:00] Player [1] killed the monster +[14:48:00] Player [1] went to the next floor +[14:48:00] Player [1] entered the boss's floor +[14:49:00] Player [1] recieved [25] of damage +[14:49:02] Player [1] has restored [80] of health +[14:50:00] Player [1] recieved [65] of damage +[14:59:00] Player [1] killed the boss +[15:04:00] Player [1] left the dungeon +Final report: +[SUCCESS] 1 [00:24:00, 00:05:00, 00:11:00] HP:35 +[FAIL] 2 [00:19:00, 00:00:00, 00:00:00] HP:0 +[DISQUAL] 3 [00:00:00, 00:00:00, 00:00:00] HP:100 +`, "\n") + + if out != want { + t.Fatalf("unexpected output\nwant:\n%s\ngot:\n%s", want, out) + } +} + +func TestHealthIsCappedAndTerminalEventsIgnored(t *testing.T) { + e, err := NewEngine(Config{Floors: 1, OpenAt: "10:00:00", Duration: 1}) + if err != nil { + t.Fatal(err) + } + e.Apply(Event{At: mustClock("10:00:00"), Player: 7, ID: 1}) + e.Apply(Event{At: mustClock("10:00:01"), Player: 7, ID: 2}) + e.Apply(Event{At: mustClock("10:00:02"), Player: 7, ID: 11, Extra: "70"}) + e.Apply(Event{At: mustClock("10:00:03"), Player: 7, ID: 10, Extra: "90"}) + e.Apply(Event{At: mustClock("10:00:04"), Player: 7, ID: 11, Extra: "100"}) + e.Apply(Event{At: mustClock("10:00:05"), Player: 7, ID: 10, Extra: "10"}) + + p := e.players[7] + if p.Health != 0 || !p.Dead || !p.Terminal { + t.Fatalf("unexpected player state: hp=%d dead=%v terminal=%v", p.Health, p.Dead, p.Terminal) + } + if got := len(e.logs); got != 6 { + t.Fatalf("terminal event was not ignored, logs=%d", got) + } +} + +func TestCloseExpiresActiveAttempt(t *testing.T) { + e, err := NewEngine(Config{Floors: 2, Monsters: 1, OpenAt: "10:00:00", Duration: 1}) + if err != nil { + t.Fatal(err) + } + e.Apply(Event{At: mustClock("10:00:00"), Player: 1, ID: 1}) + e.Apply(Event{At: mustClock("10:10:00"), Player: 1, ID: 2}) + e.Apply(Event{At: mustClock("11:30:00"), Player: 1, ID: 3}) + + p := e.players[1] + if !p.Terminal || p.EndedAt != mustClock("11:00:00") { + t.Fatalf("expected close at 11:00:00, got terminal=%v ended=%s", p.Terminal, formatDuration(p.EndedAt)) + } + if strings.Contains(e.Output(), "killed the monster") { + t.Fatal("event after close was processed") + } +} + +func mustClock(value string) time.Duration { + d, err := parseClock(value) + if err != nil { + panic(err) + } + return d +} diff --git a/internal/challenge/models.go b/internal/challenge/models.go new file mode 100644 index 0000000..3995c45 --- /dev/null +++ b/internal/challenge/models.go @@ -0,0 +1,51 @@ +package challenge + +import "time" + +type Config struct { + Floors int + Monsters int + OpenAt string + Duration int +} + +type Event struct { + At time.Duration + Player int + ID int + Extra string +} + +type ResultState string + +const ( + StateSuccess ResultState = "SUCCESS" + StateFail ResultState = "FAIL" + StateDisqual ResultState = "DISQUAL" +) + +type Player struct { + ID int + Health int + Registered bool + InDungeon bool + Attempted bool + Terminal bool + Disqual bool + Dead bool + Left bool + + EnteredAt time.Duration + EndedAt time.Duration + + Floor int + MonsterKills []int + FloorDurations []time.Duration + floorTimerOn bool + floorTimerAt time.Duration + + BossEntered bool + BossKilled bool + BossEnterAt time.Duration + BossTime time.Duration +} diff --git a/internal/challenge/runner.go b/internal/challenge/runner.go new file mode 100644 index 0000000..1831009 --- /dev/null +++ b/internal/challenge/runner.go @@ -0,0 +1,35 @@ +package challenge + +import "os" + +func Run(configPath, eventsPath string) (string, error) { + cfgFile, err := os.Open(configPath) + if err != nil { + return "", err + } + defer cfgFile.Close() + + eventsFile, err := os.Open(eventsPath) + if err != nil { + return "", err + } + defer eventsFile.Close() + + cfg, err := readConfig(cfgFile) + if err != nil { + return "", err + } + events, err := readEvents(eventsFile) + if err != nil { + return "", err + } + + engine, err := NewEngine(cfg) + if err != nil { + return "", err + } + for _, event := range events { + engine.Apply(event) + } + return engine.Output(), nil +} diff --git a/internal/challenge/timers.go b/internal/challenge/timers.go new file mode 100644 index 0000000..f01978c --- /dev/null +++ b/internal/challenge/timers.go @@ -0,0 +1,21 @@ +package challenge + +import "time" + +func (e *Engine) startFloorTimer(p *Player, at time.Duration) { + if p.Floor >= 1 && p.Floor <= e.regularFloors && !e.floorCleared(p, p.Floor) { + p.floorTimerOn = true + p.floorTimerAt = at + } +} + +func (e *Engine) pauseFloorTimer(p *Player, at time.Duration) { + if p.floorTimerOn { + p.FloorDurations[p.Floor] += at - p.floorTimerAt + p.floorTimerOn = false + } +} + +func (e *Engine) finishFloorTimer(p *Player, at time.Duration) { + e.pauseFloorTimer(p, at) +} diff --git a/internal/challenge/timeutil.go b/internal/challenge/timeutil.go new file mode 100644 index 0000000..9034df2 --- /dev/null +++ b/internal/challenge/timeutil.go @@ -0,0 +1,58 @@ +package challenge + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +const day = 24 * time.Hour + +func parseClock(value string) (time.Duration, error) { + parts := strings.Split(value, ":") + if len(parts) != 3 { + return 0, fmt.Errorf("bad time %q", value) + } + h, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, err + } + m, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, err + } + s, err := strconv.Atoi(parts[2]) + if err != nil { + return 0, err + } + if h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59 { + return 0, fmt.Errorf("bad time %q", value) + } + return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second, nil +} + +func formatClock(d time.Duration) string { + d %= day + if d < 0 { + d += day + } + h := int(d / time.Hour) + d -= time.Duration(h) * time.Hour + m := int(d / time.Minute) + d -= time.Duration(m) * time.Minute + s := int(d / time.Second) + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +func formatDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + h := int(d / time.Hour) + d -= time.Duration(h) * time.Hour + m := int(d / time.Minute) + d -= time.Duration(m) * time.Minute + s := int(d / time.Second) + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..457d612 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "impulse/internal/challenge" + "os" +) + +func main() { + configPath, eventsPath := "config.json", "events" + if len(os.Args) == 3 { + configPath, eventsPath = os.Args[1], os.Args[2] + } else if len(os.Args) != 1 { + fmt.Fprintf(os.Stderr, "usage: %s [config.json events]\n", os.Args[0]) + os.Exit(2) + } + + out, err := challenge.Run(configPath, eventsPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Print(out) +}