Show director summons as info toasts and presence queue labels.
Corridor waiters need a summon-phase byte on presence (v11); EventDef DirectorSummoned is info-only so clocks keep running.
This commit is contained in:
@@ -11,21 +11,21 @@
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] `EventDef` info на авто-вызов (ваниль в `core`); факт из симуляции → доска уведомлений
|
||||
- [x] `EventDef` info на авто-вызов (ваниль в `core`); факт из симуляции → доска уведомлений
|
||||
среза 11
|
||||
- [ ] Тост не паузит часы; не в сейве sticky
|
||||
- [ ] В панели локации / присутствии понятно «идёт к директору» / «ждёт у кабинета» / «на приёме»
|
||||
- [x] Тост не паузит часы; не в сейве sticky
|
||||
- [x] В панели локации / присутствии понятно «идёт к директору» / «ждёт у кабинета» / «на приёме»
|
||||
— через действие и узел; новый байт в кадре присутствия — только если иначе нельзя; тогда
|
||||
протокол +1 в этом коммите (`ProtocolCodec`, `protocol.ts`, `docs/protocol.md`)
|
||||
- [ ] Гость видит те же info; ничего не назначает
|
||||
- [ ] Локали RU/EN
|
||||
- [x] Гость видит те же info; ничего не назначает
|
||||
- [x] Локали RU/EN
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Qualifying-вызов эмитит notice с ожидаемым `EventDef`
|
||||
- [ ] Info не пишет sticky в сейв
|
||||
- [ ] Если бамп протокола — round-trip и byte-layout на C# и TS
|
||||
- [ ] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима)
|
||||
- [x] Qualifying-вызов эмитит notice с ожидаемым `EventDef`
|
||||
- [x] Info не пишет sticky в сейв
|
||||
- [x] Если бамп протокола — round-trip и byte-layout на C# и TS
|
||||
- [x] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима)
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
# Wire protocol v10
|
||||
# Wire protocol v11
|
||||
|
||||
The client talks to the server two ways:
|
||||
|
||||
@@ -1159,10 +1159,13 @@ Each person:
|
||||
| `u8` | `1` here, `2` walking |
|
||||
| `u8` | talk-circle member count, then that many person-id strings |
|
||||
| string | topic id (empty when not in a circle) |
|
||||
| `u8` | summon phase: `0` none, `1` going to the principal, `2` waiting in the corridor, `3` in a hearing |
|
||||
|
||||
Member ids are the live circle, including self, sorted by id. Count `0` and an empty topic mean
|
||||
the person is not talking — the same id/node/state as before the circle fields. Names are not
|
||||
on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale.
|
||||
Summon phase is a dedicated byte because node + here/walking alone cannot tell a corridor waiter
|
||||
from a passer-by (protocol v11).
|
||||
|
||||
### `0x87` Notice — variable
|
||||
|
||||
@@ -1197,7 +1200,7 @@ Layout extra after the v10 personId field; protocol version stays 10.
|
||||
the oldest, because a stale clock is worthless once a newer one exists.
|
||||
- The map snapshot, presence and notices use a separate reliable queue so ticks cannot crowd them out.
|
||||
|
||||
## Not in v10 yet
|
||||
## Not in v11 yet
|
||||
|
||||
Authentication, Sit orders, a year-long event log, walk animation, and `OpenLocation` on the server —
|
||||
the tree and the location panel are filtered on the client from the snapshot plus presence.
|
||||
|
||||
@@ -10,6 +10,24 @@ public static class DirectorSummons
|
||||
{
|
||||
public const string HearingAction = "PrincipalHearing";
|
||||
|
||||
/// <summary>Presence / card label while walking to the office or wait node. Not a planner pick.</summary>
|
||||
public const string GoingAction = "GoingToPrincipal";
|
||||
|
||||
/// <summary>Presence / card label while queued in the corridor. Not a planner pick.</summary>
|
||||
public const string WaitingAction = "WaitForPrincipal";
|
||||
|
||||
/// <summary>Presence frame byte when not under an auto summons.</summary>
|
||||
public const byte PresenceNone = 0;
|
||||
|
||||
/// <summary>Walking toward the wait node or office.</summary>
|
||||
public const byte PresenceGoing = 1;
|
||||
|
||||
/// <summary>Idle in the corridor queue.</summary>
|
||||
public const byte PresenceWaiting = 2;
|
||||
|
||||
/// <summary>In the office hearing (or admitted and already there).</summary>
|
||||
public const byte PresenceHearing = 3;
|
||||
|
||||
public const string OfficeRoomDef = "PrincipalsOffice";
|
||||
|
||||
public const string CorridorRoomDef = "Corridor";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PresenceState, type PresencePerson } from '../net/protocol.ts';
|
||||
import { PresenceState, PresenceSummonPhase, type PresencePerson } from '../net/protocol.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { locationPersonLine, talkCircleText } from './talkCircle.ts';
|
||||
@@ -20,6 +20,7 @@ describe('location talk line', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
summonPhase: PresenceSummonPhase.None,
|
||||
};
|
||||
const names = new Map([
|
||||
['f0.c0', 'Иванова Мария'],
|
||||
@@ -42,10 +43,42 @@ describe('location talk line', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
summonPhase: PresenceSummonPhase.None,
|
||||
};
|
||||
const names = new Map([['f0.c0', 'Иванова Мария']]);
|
||||
|
||||
expect(locationPersonLine(person, names)).toBe('Иванова Мария');
|
||||
expect(talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names)).toBe('');
|
||||
});
|
||||
|
||||
it('labels waiting and hearing summons on the location line', () => {
|
||||
setLocale('ru');
|
||||
const names = new Map([['f0.c0', 'Иванова Мария']]);
|
||||
expect(
|
||||
locationPersonLine(
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
summonPhase: PresenceSummonPhase.Waiting,
|
||||
},
|
||||
names,
|
||||
),
|
||||
).toBe(t('locationWaitingForPrincipal', { name: 'Иванова Мария' }));
|
||||
expect(
|
||||
locationPersonLine(
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'principals-office',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
summonPhase: PresenceSummonPhase.Hearing,
|
||||
},
|
||||
names,
|
||||
),
|
||||
).toBe(t('locationPrincipalHearing', { name: 'Иванова Мария' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PresenceState, type PresencePerson } from '../net/protocol.ts';
|
||||
import { PresenceState, PresenceSummonPhase, type PresencePerson } from '../net/protocol.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
|
||||
const TOPIC_KEYS: Record<string, MessageKey> = {
|
||||
@@ -58,7 +58,25 @@ export function talkCircleText(
|
||||
|
||||
export function locationPersonLine(person: PresencePerson, names: ReadonlyMap<string, string>): string {
|
||||
const name = names.get(person.id) ?? person.id;
|
||||
const summon = summonPresenceLabel(person.summonPhase, name);
|
||||
if (summon.length > 0) {
|
||||
return summon;
|
||||
}
|
||||
|
||||
const base = person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
|
||||
const talk = talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names);
|
||||
return talk.length > 0 ? `${base} — ${talk}` : base;
|
||||
}
|
||||
|
||||
function summonPresenceLabel(phase: number, name: string): string {
|
||||
switch (phase) {
|
||||
case PresenceSummonPhase.Going:
|
||||
return t('locationGoingToPrincipal', { name });
|
||||
case PresenceSummonPhase.Waiting:
|
||||
return t('locationWaitingForPrincipal', { name });
|
||||
case PresenceSummonPhase.Hearing:
|
||||
return t('locationPrincipalHearing', { name });
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,9 @@ const ru = {
|
||||
presenceWalking: 'в пути ({name})',
|
||||
presenceAway: 'вне школы',
|
||||
locationWalking: '{name} (в пути)',
|
||||
locationGoingToPrincipal: '{name} — идёт к директору',
|
||||
locationWaitingForPrincipal: '{name} — ждёт у кабинета',
|
||||
locationPrincipalHearing: '{name} — на приёме',
|
||||
locationTalking: 'говорит с {partners} о {topic}',
|
||||
talkPartnersJoin: '{head} и {last}',
|
||||
talkTopicTopicStudy: 'учёбе',
|
||||
@@ -398,6 +401,7 @@ const ru = {
|
||||
DayStarted: 'Начало дня',
|
||||
LessonStarted: 'Начало урока',
|
||||
GenerationFailed: 'Не удалось нарисовать портрет',
|
||||
DirectorSummoned: 'Ученика вызвали к директору',
|
||||
noticeDismiss: 'Закрыть',
|
||||
noticeGenerateImage: 'Создать картинку',
|
||||
noticeGenerateImageBusy: 'Рисуем…',
|
||||
@@ -773,6 +777,9 @@ const en: Messages = {
|
||||
presenceWalking: 'walking ({name})',
|
||||
presenceAway: 'off campus',
|
||||
locationWalking: '{name} (walking)',
|
||||
locationGoingToPrincipal: '{name} — going to the principal',
|
||||
locationWaitingForPrincipal: '{name} — waiting at the office',
|
||||
locationPrincipalHearing: '{name} — in a hearing',
|
||||
locationTalking: 'talking with {partners} about {topic}',
|
||||
talkPartnersJoin: '{head} and {last}',
|
||||
talkTopicTopicStudy: 'schoolwork',
|
||||
@@ -804,6 +811,7 @@ const en: Messages = {
|
||||
DayStarted: 'The day has started',
|
||||
LessonStarted: 'A lesson has started',
|
||||
GenerationFailed: 'Portrait generation failed',
|
||||
DirectorSummoned: 'A pupil was summoned to the principal',
|
||||
noticeDismiss: 'Close',
|
||||
noticeGenerateImage: 'Create image',
|
||||
noticeGenerateImageBusy: 'Drawing…',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
encodeDismissNotice,
|
||||
MessageType,
|
||||
PresenceState,
|
||||
PresenceSummonPhase,
|
||||
ProtocolError,
|
||||
PROTOCOL_VERSION,
|
||||
} from './protocol.ts';
|
||||
@@ -274,7 +275,8 @@ describe('decodeServerMessage', () => {
|
||||
+ 2 + personNode.length
|
||||
+ 1
|
||||
+ 1
|
||||
+ 2,
|
||||
+ 2
|
||||
+ 1,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
@@ -312,6 +314,8 @@ describe('decodeServerMessage', () => {
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint16(offset, 0, true);
|
||||
offset += 2;
|
||||
view.setUint8(offset, PresenceSummonPhase.None);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
@@ -331,6 +335,7 @@ describe('decodeServerMessage', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
summonPhase: PresenceSummonPhase.None,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -358,7 +363,8 @@ describe('decodeServerMessage', () => {
|
||||
+ 1
|
||||
+ 2 + selfId.length
|
||||
+ 2 + partnerId.length
|
||||
+ 2 + topic.length,
|
||||
+ 2 + topic.length
|
||||
+ 1,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
@@ -374,7 +380,8 @@ describe('decodeServerMessage', () => {
|
||||
offset += 1;
|
||||
offset = putString(view, offset, selfId);
|
||||
offset = putString(view, offset, partnerId);
|
||||
putString(view, offset, topic);
|
||||
offset = putString(view, offset, topic);
|
||||
view.setUint8(offset, PresenceSummonPhase.None);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
@@ -387,6 +394,7 @@ describe('decodeServerMessage', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
summonPhase: PresenceSummonPhase.None,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -404,7 +412,7 @@ describe('decodeServerMessage', () => {
|
||||
const selfId = encoder.encode('f0.c0');
|
||||
const topic = encoder.encode('TopicSport');
|
||||
const buffer = new ArrayBuffer(
|
||||
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length,
|
||||
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length + 1,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
@@ -419,7 +427,8 @@ describe('decodeServerMessage', () => {
|
||||
view.setUint8(offset, 1);
|
||||
offset += 1;
|
||||
offset = putString(view, offset, selfId);
|
||||
putString(view, offset, topic);
|
||||
offset = putString(view, offset, topic);
|
||||
view.setUint8(offset, PresenceSummonPhase.None);
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
@@ -439,6 +448,53 @@ describe('decodeServerMessage', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0'],
|
||||
talkTopicId: 'TopicSport',
|
||||
summonPhase: PresenceSummonPhase.None,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a presence summon phase byte after the topic', () => {
|
||||
const encoder = new TextEncoder();
|
||||
const putString = (target: DataView, at: number, text: Uint8Array): number => {
|
||||
target.setUint16(at, text.length, true);
|
||||
new Uint8Array(target.buffer).set(text, at + 2);
|
||||
return at + 2 + text.length;
|
||||
};
|
||||
|
||||
const corridor = encoder.encode('corridor-1');
|
||||
const selfId = encoder.encode('f0.c0');
|
||||
const buffer = new ArrayBuffer(
|
||||
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + 1,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
view.setInt32(1, 1, true);
|
||||
view.setUint16(5, 0, true);
|
||||
view.setUint16(7, 1, true);
|
||||
let offset = 9;
|
||||
offset = putString(view, offset, selfId);
|
||||
offset = putString(view, offset, corridor);
|
||||
view.setUint8(offset, PresenceState.Here);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint16(offset, 0, true);
|
||||
offset += 2;
|
||||
view.setUint8(offset, PresenceSummonPhase.Waiting);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
schoolId: 1,
|
||||
nodes: [],
|
||||
people: [
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
summonPhase: PresenceSummonPhase.Waiting,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 10;
|
||||
export const PROTOCOL_VERSION = 11;
|
||||
|
||||
export const MessageType = {
|
||||
ClientHello: 0x01,
|
||||
@@ -113,6 +113,14 @@ export const PresenceState = {
|
||||
Walking: 2,
|
||||
} as const;
|
||||
|
||||
/** Auto director summons on the presence person. Matches server PresenceSummonPhase. */
|
||||
export const PresenceSummonPhase = {
|
||||
None: 0,
|
||||
Going: 1,
|
||||
Waiting: 2,
|
||||
Hearing: 3,
|
||||
} as const;
|
||||
|
||||
export interface PresenceNode {
|
||||
readonly id: string;
|
||||
readonly count: number;
|
||||
@@ -126,6 +134,8 @@ export interface PresencePerson {
|
||||
readonly state: number;
|
||||
readonly talkMemberIds: readonly string[];
|
||||
readonly talkTopicId: string;
|
||||
/** PresenceSummonPhase; 0 when not summoned. */
|
||||
readonly summonPhase: number;
|
||||
}
|
||||
|
||||
export interface PresenceMessage {
|
||||
@@ -420,12 +430,15 @@ function decodePresence(view: DataView): PresenceMessage {
|
||||
|
||||
const topic = readString(view, offset);
|
||||
offset = topic.next;
|
||||
const summonPhase = readU8(view, offset);
|
||||
offset += 1;
|
||||
people.push({
|
||||
id: id.text,
|
||||
nodeId: nodeId.text,
|
||||
state,
|
||||
talkMemberIds,
|
||||
talkTopicId: topic.text,
|
||||
summonPhase,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ describe('GameScreen location talk', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
summonPhase: 0,
|
||||
},
|
||||
{
|
||||
id: 'f0.c1',
|
||||
@@ -273,6 +274,7 @@ describe('GameScreen location talk', () => {
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
summonPhase: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export function noticeLabel(defName: string): string {
|
||||
case 'DayStarted':
|
||||
case 'LessonStarted':
|
||||
case 'GenerationFailed':
|
||||
case 'DirectorSummoned':
|
||||
return t(defName satisfies MessageKey);
|
||||
default:
|
||||
return defName;
|
||||
|
||||
@@ -14,6 +14,7 @@ internal static class EventDefValidator
|
||||
EventTriggers.DayStart,
|
||||
EventTriggers.LessonStart,
|
||||
EventTriggers.GenerationFailed,
|
||||
EventTriggers.DirectorSummon,
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
|
||||
|
||||
@@ -12,6 +12,7 @@ public static class EventTriggers
|
||||
public const string DayStart = "dayStart";
|
||||
public const string LessonStart = "lessonStart";
|
||||
public const string GenerationFailed = "generationFailed";
|
||||
public const string DirectorSummon = "directorSummon";
|
||||
}
|
||||
|
||||
public static class EventActions
|
||||
|
||||
@@ -122,6 +122,19 @@ public static class PresenceState
|
||||
public const byte Walking = 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto director summons on the presence frame. Node + here/walking alone cannot tell a corridor
|
||||
/// waiter from a passer-by, so this is a dedicated byte (protocol v11). Values match
|
||||
/// <c>HSchool.Ai.DirectorSummons.Presence*</c>.
|
||||
/// </summary>
|
||||
public static class PresenceSummonPhase
|
||||
{
|
||||
public const byte None = 0;
|
||||
public const byte Going = 1;
|
||||
public const byte Waiting = 2;
|
||||
public const byte Hearing = 3;
|
||||
}
|
||||
|
||||
/// <summary>One occupied (or currently taught) map node in a presence frame.</summary>
|
||||
public sealed record PresenceNode(
|
||||
string Id,
|
||||
@@ -132,16 +145,18 @@ public sealed record PresenceNode(
|
||||
/// <summary>
|
||||
/// One on-campus person. Names are resolved over HTTP, not on this frame. Talk members and
|
||||
/// topic are ids; an empty list means the person is not in a circle.
|
||||
/// <see cref="SummonPhase"/> is <see cref="PresenceSummonPhase"/> (0 when not summoned).
|
||||
/// </summary>
|
||||
public sealed record PresencePerson(
|
||||
string Id,
|
||||
string NodeId,
|
||||
byte State,
|
||||
IReadOnlyList<string> TalkMemberIds,
|
||||
string TalkTopicId)
|
||||
string TalkTopicId,
|
||||
byte SummonPhase = PresenceSummonPhase.None)
|
||||
{
|
||||
public PresencePerson(string id, string nodeId, byte state)
|
||||
: this(id, nodeId, state, [], "")
|
||||
: this(id, nodeId, state, [], "", PresenceSummonPhase.None)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ public static class ProtocolCodec
|
||||
}
|
||||
|
||||
size += StringSize(person.TalkTopicId ?? "");
|
||||
size += sizeof(byte);
|
||||
}
|
||||
|
||||
return size;
|
||||
@@ -274,6 +275,7 @@ public static class ProtocolCodec
|
||||
}
|
||||
|
||||
writer.WriteString(person.TalkTopicId ?? "");
|
||||
writer.WriteByte(person.SummonPhase);
|
||||
}
|
||||
|
||||
return writer.Position;
|
||||
@@ -460,7 +462,8 @@ public static class ProtocolCodec
|
||||
}
|
||||
|
||||
var topicId = reader.ReadString();
|
||||
people[i] = new PresencePerson(id, nodeId, state, members, topicId);
|
||||
var summonPhase = reader.ReadByte();
|
||||
people[i] = new PresencePerson(id, nodeId, state, members, topicId, summonPhase);
|
||||
}
|
||||
|
||||
return new ServerPresenceMessage(schoolId, nodes, people);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||
public const byte Version = 10;
|
||||
public const byte Version = 11;
|
||||
|
||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||
public const int MaxMessageSize = 8 * 1024;
|
||||
|
||||
@@ -61,7 +61,8 @@ internal static partial class PersonCardReader
|
||||
|
||||
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
|
||||
var skills = LiveSkills(school.World, personId);
|
||||
var activityId = LiveActivity(school.World, personId);
|
||||
var activityId = LiveActivity(school.World, personId)
|
||||
?? school.DirectorSummonPresenceActionId(personId);
|
||||
string? activityLabel = null;
|
||||
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
|
||||
{
|
||||
|
||||
@@ -26,12 +26,14 @@ internal static class PresenceFrame
|
||||
|
||||
var walking = row.Path.Count > 0 || row.RemainingMinutes > 0;
|
||||
var circle = school.TalkCircleOf(row.PersonId);
|
||||
var summonPhase = school.DirectorSummonPresencePhase(row.PersonId);
|
||||
people.Add(new PresencePerson(
|
||||
row.PersonId,
|
||||
row.NodeId,
|
||||
walking ? PresenceState.Walking : PresenceState.Here,
|
||||
circle?.MemberIds ?? [],
|
||||
circle?.TopicId ?? ""));
|
||||
circle?.TopicId ?? "",
|
||||
summonPhase));
|
||||
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
|
||||
continue;
|
||||
}
|
||||
|
||||
TryEmitNotice(school, def);
|
||||
TryEmitNotice(school, def, fact.PersonKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,21 @@
|
||||
"roles": ["student"],
|
||||
"weight": 0,
|
||||
},
|
||||
{
|
||||
// Wire / card labels for summons presence (phase 71). Weight 0 — never started by the planner.
|
||||
"defName": "GoingToPrincipal",
|
||||
"room": "Corridor",
|
||||
"minutes": 1,
|
||||
"roles": ["student"],
|
||||
"weight": 0,
|
||||
},
|
||||
{
|
||||
"defName": "WaitForPrincipal",
|
||||
"room": "Corridor",
|
||||
"minutes": 1,
|
||||
"roles": ["student"],
|
||||
"weight": 0,
|
||||
},
|
||||
{
|
||||
"defName": "ChangeClothesMale",
|
||||
"room": "MaleChangingRoom",
|
||||
|
||||
@@ -23,4 +23,12 @@
|
||||
"trigger": "generationFailed",
|
||||
"action": "generateImage",
|
||||
},
|
||||
{
|
||||
"defName": "DirectorSummoned",
|
||||
"severity": "info",
|
||||
"pause": false,
|
||||
"ttlMs": 8000,
|
||||
"trigger": "directorSummon",
|
||||
"action": "none",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -231,5 +231,8 @@
|
||||
"DayStarted": "The day has started",
|
||||
"LessonStarted": "A lesson has started",
|
||||
"GenerationFailed": "Portrait generation failed",
|
||||
"DirectorSummoned": "A pupil was summoned to the principal",
|
||||
"GoingToPrincipal": "going to the principal",
|
||||
"WaitForPrincipal": "waiting at the principal's office",
|
||||
"core": "Core",
|
||||
}
|
||||
|
||||
@@ -231,5 +231,8 @@
|
||||
"DayStarted": "Начало дня",
|
||||
"LessonStarted": "Начало урока",
|
||||
"GenerationFailed": "Не удалось нарисовать портрет",
|
||||
"DirectorSummoned": "Ученика вызвали к директору",
|
||||
"GoingToPrincipal": "идёт к директору",
|
||||
"WaitForPrincipal": "ждёт у кабинета директора",
|
||||
"core": "Базовая игра",
|
||||
}
|
||||
|
||||
@@ -104,9 +104,73 @@ internal static class DirectorSummonSystem
|
||||
|
||||
school.DirectorSummons.Add(new DirectorSummonTicket(personId, school.NextSummonOrder++, admitted: false));
|
||||
PresenceSystem.Enqueue(school, personId);
|
||||
school.RaiseWorldEvent(new WorldEvent(EventTriggers.DirectorSummon, personId));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire tag for the presence frame: going / waiting / hearing. Node + state alone cannot tell
|
||||
/// a corridor waiter from a passer-by, so this is a dedicated byte (protocol bump).
|
||||
/// </summary>
|
||||
public static byte ResolvePresencePhase(School school, string personId)
|
||||
{
|
||||
var ticket = school.DirectorSummons.FirstOrDefault(row =>
|
||||
row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||
if (ticket is null)
|
||||
{
|
||||
return DirectorSummons.PresenceNone;
|
||||
}
|
||||
|
||||
var walking = false;
|
||||
var hearing = false;
|
||||
string? nodeId = null;
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
|
||||
{
|
||||
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nodeId = presence.NodeId;
|
||||
walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|
||||
|| (presence.DestinationId is not null
|
||||
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
|
||||
hearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId);
|
||||
});
|
||||
|
||||
if (hearing)
|
||||
{
|
||||
return DirectorSummons.PresenceHearing;
|
||||
}
|
||||
|
||||
if (walking)
|
||||
{
|
||||
return DirectorSummons.PresenceGoing;
|
||||
}
|
||||
|
||||
var office = DirectorSummons.OfficeNodeId(school.Map!);
|
||||
if (ticket.Admitted
|
||||
&& office is not null
|
||||
&& nodeId is not null
|
||||
&& nodeId.Equals(office, StringComparison.Ordinal))
|
||||
{
|
||||
return DirectorSummons.PresenceHearing;
|
||||
}
|
||||
|
||||
return DirectorSummons.PresenceWaiting;
|
||||
}
|
||||
|
||||
/// <summary>ActionDef name for the person card when the live activity is idle but summoned.</summary>
|
||||
public static string? PresenceActionId(byte phase) => phase switch
|
||||
{
|
||||
DirectorSummons.PresenceGoing => DirectorSummons.GoingAction,
|
||||
DirectorSummons.PresenceWaiting => DirectorSummons.WaitingAction,
|
||||
DirectorSummons.PresenceHearing => DirectorSummons.HearingAction,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public static void ClearAll(School school)
|
||||
{
|
||||
if (school.DirectorSummons.Count == 0)
|
||||
|
||||
@@ -426,6 +426,20 @@ public sealed class School : IDisposable
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Queue a fact for the worker's notice board. Simulation never builds UI itself.</summary>
|
||||
internal void RaiseWorldEvent(in WorldEvent fact) => _worldEvents.Add(fact);
|
||||
|
||||
/// <summary>
|
||||
/// Presence wire tag for auto director summons. Values match <c>DirectorSummons.Presence*</c>
|
||||
/// and <see cref="Protocol.PresenceSummonPhase"/>.
|
||||
/// </summary>
|
||||
public byte DirectorSummonPresencePhase(string personId) =>
|
||||
DirectorSummonSystem.ResolvePresencePhase(this, personId);
|
||||
|
||||
/// <summary>ActionDef id for the person card when summoned but the live activity is idle.</summary>
|
||||
public string? DirectorSummonPresenceActionId(string personId) =>
|
||||
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
|
||||
|
||||
private void RecordWorldEvents(DateTime before, DateTime after)
|
||||
{
|
||||
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
|
||||
|
||||
@@ -2,5 +2,6 @@ namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// A fact the school raised this step. Same seam as <c>AffinityEvent</c>: a list, no UI.
|
||||
/// <see cref="PersonKey"/> is empty when the fact is school-wide (morning, lesson bell).
|
||||
/// </summary>
|
||||
public readonly record struct WorldEvent(string Trigger);
|
||||
public readonly record struct WorldEvent(string Trigger, string PersonKey = "");
|
||||
|
||||
@@ -33,6 +33,15 @@ public class EventDefTests
|
||||
Assert.Equal(EventActions.GenerateImage, catalog.Events["GenerationFailed"].Action);
|
||||
Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"]));
|
||||
Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"]));
|
||||
|
||||
Assert.True(catalog.Events.ContainsKey("DirectorSummoned"));
|
||||
Assert.Equal(EventSeverities.Info, catalog.Events["DirectorSummoned"].Severity);
|
||||
Assert.False(catalog.Events["DirectorSummoned"].Pause);
|
||||
Assert.Equal(8000, catalog.Events["DirectorSummoned"].TtlMs);
|
||||
Assert.Equal(EventTriggers.DirectorSummon, catalog.Events["DirectorSummoned"].Trigger);
|
||||
Assert.Equal(EventActions.None, catalog.Events["DirectorSummoned"].Action);
|
||||
Assert.Equal("Ученика вызвали к директору", catalog.Label("ru", catalog.Events["DirectorSummoned"]));
|
||||
Assert.Equal("A pupil was summoned to the principal", catalog.Label("en", catalog.Events["DirectorSummoned"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -285,8 +285,10 @@ public class ProtocolCodecTests
|
||||
Assert.Equal(PresenceState.Here, read.People[0].State);
|
||||
Assert.Empty(read.People[0].TalkMemberIds);
|
||||
Assert.Equal("", read.People[0].TalkTopicId);
|
||||
Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
|
||||
Assert.Equal(PresenceState.Walking, read.People[1].State);
|
||||
Assert.Empty(read.People[1].TalkMemberIds);
|
||||
Assert.Equal(PresenceSummonPhase.None, read.People[1].SummonPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -313,12 +315,15 @@ public class ProtocolCodecTests
|
||||
Assert.Equal(0, buffer[offset]);
|
||||
offset += 1;
|
||||
offset = AssertWireString(buffer, offset, "");
|
||||
Assert.Equal(PresenceSummonPhase.None, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal(length, offset);
|
||||
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
|
||||
|
||||
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||
Assert.Empty(read.People[0].TalkMemberIds);
|
||||
Assert.Equal("", read.People[0].TalkTopicId);
|
||||
Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
|
||||
Assert.Equal(message.People[0].Id, read.People[0].Id);
|
||||
Assert.Equal(message.People[0].NodeId, read.People[0].NodeId);
|
||||
Assert.Equal(message.People[0].State, read.People[0].State);
|
||||
@@ -357,16 +362,52 @@ public class ProtocolCodecTests
|
||||
offset = AssertWireString(buffer, offset, "f0.c0");
|
||||
offset = AssertWireString(buffer, offset, "f0.c1");
|
||||
offset = AssertWireString(buffer, offset, "TopicSport");
|
||||
Assert.Equal(PresenceSummonPhase.None, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal(length, offset);
|
||||
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
|
||||
|
||||
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||
Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds);
|
||||
Assert.Equal("TopicSport", read.People[0].TalkTopicId);
|
||||
Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
|
||||
Assert.Equal(message.SchoolId, read.SchoolId);
|
||||
Assert.Equal(message.People[0].Id, read.People[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_SummonPhase_RoundTripsAndMatchesByteLayout()
|
||||
{
|
||||
var message = new ServerPresenceMessage(
|
||||
1,
|
||||
[],
|
||||
[new PresencePerson(
|
||||
"f0.c0",
|
||||
"corridor-1",
|
||||
PresenceState.Here,
|
||||
[],
|
||||
"",
|
||||
PresenceSummonPhase.Waiting)]);
|
||||
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
|
||||
|
||||
var length = ProtocolCodec.WritePresence(buffer, message);
|
||||
|
||||
var offset = 9;
|
||||
offset = AssertWireString(buffer, offset, "f0.c0");
|
||||
offset = AssertWireString(buffer, offset, "corridor-1");
|
||||
Assert.Equal(PresenceState.Here, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal(0, buffer[offset]);
|
||||
offset += 1;
|
||||
offset = AssertWireString(buffer, offset, "");
|
||||
Assert.Equal(PresenceSummonPhase.Waiting, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal(length, offset);
|
||||
|
||||
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||
Assert.Equal(PresenceSummonPhase.Waiting, Assert.Single(read.People).SummonPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_DoesNotContainPersonDisplayName()
|
||||
{
|
||||
|
||||
@@ -95,6 +95,18 @@ public class NoticeBoardTests
|
||||
Assert.Equal("avatar", sticky.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectorSummonedInfo_IsNotStickyInSave()
|
||||
{
|
||||
var board = new NoticeBoard(maxSticky: 8);
|
||||
|
||||
Assert.True(board.TryPost(DirectorSummoned(), personKey: "a0.p0", kind: "", out var info));
|
||||
Assert.Equal("DirectorSummoned", info.DefName);
|
||||
Assert.False(info.Pause);
|
||||
Assert.Empty(board.Sticky);
|
||||
Assert.DoesNotContain(board.ToSave(), row => row.DefName == "DirectorSummoned");
|
||||
}
|
||||
|
||||
private static EventDef Info() => new()
|
||||
{
|
||||
DefName = "DayStarted",
|
||||
@@ -105,6 +117,16 @@ public class NoticeBoardTests
|
||||
Action = EventActions.None,
|
||||
};
|
||||
|
||||
private static EventDef DirectorSummoned() => new()
|
||||
{
|
||||
DefName = "DirectorSummoned",
|
||||
Severity = EventSeverities.Info,
|
||||
Pause = false,
|
||||
TtlMs = 8000,
|
||||
Trigger = EventTriggers.DirectorSummon,
|
||||
Action = EventActions.None,
|
||||
};
|
||||
|
||||
private static EventDef Pausing() => new()
|
||||
{
|
||||
DefName = "GenerationFailed",
|
||||
|
||||
@@ -112,7 +112,16 @@ public class DirectorSummonTests
|
||||
name => name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(
|
||||
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
|
||||
method => method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
|
||||
method => method.Name.StartsWith("Set", StringComparison.Ordinal)
|
||||
&& method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(
|
||||
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
|
||||
method => method.Name.StartsWith("Send", StringComparison.Ordinal)
|
||||
&& method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(
|
||||
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
|
||||
method => method.Name.StartsWith("Enqueue", StringComparison.Ordinal)
|
||||
&& method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,6 +132,67 @@ public class DirectorSummonTests
|
||||
Assert.Equal("corridor-1", DirectorSummons.WaitNodeId(map));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualifyingSummon_RaisesDirectorSummonWorldEvent()
|
||||
{
|
||||
var (school, first, _, _) = TwoPupilsPrincipalAndTeacherOnBreak();
|
||||
using (school)
|
||||
{
|
||||
school.DrainWorldEvents();
|
||||
Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
|
||||
|
||||
var facts = school.DrainWorldEvents();
|
||||
var fact = Assert.Single(facts, row => row.Trigger.Equals(EventTriggers.DirectorSummon, StringComparison.Ordinal));
|
||||
Assert.Equal(first.Id, fact.PersonKey);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueAdvancesWithoutOpenClient()
|
||||
{
|
||||
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
|
||||
using (school)
|
||||
{
|
||||
Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
|
||||
Assert.True(DirectorSummonSystem.TryEnqueue(school, second.Id, chance: 1f));
|
||||
DirectorSummonSystem.Apply(school);
|
||||
|
||||
var head = school.DirectorSummons.OrderBy(row => row.Order).First();
|
||||
var next = school.DirectorSummons.OrderBy(row => row.Order).Skip(1).First();
|
||||
PlaceAt(school, head.PersonId, "principals-office");
|
||||
DirectorSummonSystem.Apply(school);
|
||||
Assert.True(ActivitySystem.IsPrincipalHearing(ActivityOf(school, head.PersonId)));
|
||||
|
||||
DirectorSummonSystem.CompleteHearing(school, head.PersonId);
|
||||
DirectorSummonSystem.Apply(school);
|
||||
|
||||
Assert.False(DirectorSummonSystem.IsSummoned(school, head.PersonId));
|
||||
Assert.True(DirectorSummonSystem.IsSummoned(school, next.PersonId));
|
||||
PlaceAt(school, next.PersonId, "corridor-1");
|
||||
Assert.Equal(DirectorSummons.PresenceWaiting, school.DirectorSummonPresencePhase(next.PersonId));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SummonPresencePhase_WaitingInCorridor_HearingInOffice()
|
||||
{
|
||||
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
|
||||
using (school)
|
||||
{
|
||||
Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
|
||||
Assert.True(DirectorSummonSystem.TryEnqueue(school, second.Id, chance: 1f));
|
||||
DirectorSummonSystem.Apply(school);
|
||||
|
||||
var ordered = school.DirectorSummons.OrderBy(row => row.Order).ToArray();
|
||||
PlaceAt(school, ordered[0].PersonId, "principals-office");
|
||||
PlaceAt(school, ordered[1].PersonId, "corridor-1");
|
||||
DirectorSummonSystem.Apply(school);
|
||||
|
||||
Assert.Equal(DirectorSummons.PresenceHearing, school.DirectorSummonPresencePhase(ordered[0].PersonId));
|
||||
Assert.Equal(DirectorSummons.PresenceWaiting, school.DirectorSummonPresencePhase(ordered[1].PersonId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> QueueOrderFromFight(int seed)
|
||||
{
|
||||
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak(seed);
|
||||
|
||||
Reference in New Issue
Block a user