1
0
Fork 0
This commit is contained in:
Arthur K. 2026-05-14 20:19:22 +03:00
commit 7cc71fb719
Signed by: wzray
GPG key ID: B97F30FDC4636357
13 changed files with 840 additions and 0 deletions

140
README.md Normal file
View file

@ -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
```

6
config.json Normal file
View file

@ -0,0 +1,6 @@
{
"Floors": 2,
"Monsters": 2,
"OpenAt": "14:05:00",
"Duration": 2
}

20
events Normal file
View file

@ -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

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module impulse
go 1.22

View file

@ -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)
}

View file

@ -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...)))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

24
main.go Normal file
View file

@ -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)
}